<!-- Generated by `pnpm generate:llms`. Do not edit by hand. -->

# Every REST convention already has a file in Laravel

*2026-08-15*

> Route, middleware, Form Request, controller method, Resource. I keep finding all five rebuilt inside one method, and Laravel already named the file.

Most Laravel APIs I review aren't wrong about REST. They're wrong about where REST lives. The developer knows the endpoint should be a noun, knows input needs validating, knows the response shouldn't leak a password hash — and then does all three inside one controller method.

Laravel already has a file for each of those. A request travels a fixed path from the route to the JSON, and every REST convention I care about belongs to exactly one stop on that path. The mistake isn't ignorance of REST. It's putting REST in the wrong place.

That's the pipeline:

- `Route::apiResource` maps the HTTP verb to a controller method
- middleware decides whether the caller gets in at all
- a Form Request authorizes the action and validates the payload
- the controller method does the work
- an API Resource decides what the client is allowed to see

Five stops. If you can say which stop a rule belongs to, you can find it three months later.

## The route declares the contract, not the controller

One line replaces five route definitions and, more importantly, five naming decisions:

```php
Route::apiResource('posts', PostController::class);
```

That gives you `GET /posts` → `index`, `POST /posts` → `store`, `GET /posts/{post}` → `show`, `PUT|PATCH /posts/{post}` → `update`, `DELETE /posts/{post}` → `destroy`. The verb carries the operation, so the path stays a noun. `GET /getPosts` and `POST /updatePostStatus` are the same mistake twice: an operation smuggled into the URL because the verb wasn't trusted to carry it.

Route model binding comes free. `{post}` resolves to a `Post` instance before your method runs, and a bad ID is a 404 you didn't write.

When a resource genuinely doesn't need all five, say so in the route rather than leaving a dead method behind:

```php
Route::apiResource('posts', PostController::class)->only(['index', 'show']);
```

`php artisan route:list` is the cheapest review I know. If the output has a verb in a path, or two routes that do the same thing, that's the review comment — before anyone reads a controller.

## Middleware is the door, not the guard

This is the stop people skip when they describe the path, and it matters that it comes *before* validation. Authentication, throttling, and anything that applies to a whole group of routes happen here:

```php
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
    Route::apiResource('posts', PostController::class);
});
```

Custom middleware is rare, and it should stay rare. It's the right place for a check that has nothing to do with a specific record — an inactive subscription, a tenant the caller doesn't belong to, an API version that's gone. It's the wrong place for "can this user edit this post," because middleware runs on the route, not on the model.

That's the test I use: if the rule needs to load the record to answer, it isn't middleware. It's a policy, one stop down.

## One Form Request per operation

Not one per resource, and never one class with a `switch` on the HTTP method. `StorePostRequest` and `UpdatePostRequest` are different contracts. Create requires a title; update usually doesn't. Collapsing them is how a required field quietly becomes optional.

The rules should read like documentation, because they are the only documentation that can't go stale:

```php
public function rules(): array
{
    return [
        'title' => ['required', 'string', 'max:255'],
        'body' => ['required', 'string'],
        'category_id' => ['required', 'integer', 'exists:categories,id'],
        'published_at' => ['nullable', 'date', 'after_or_equal:today'],
    ];
}
```

Be specific. `'title' => 'required'` accepts an array, an integer, and a 60,000-character essay. Every field gets a type, and a bound if the column has one.

Then write the messages. This is the part that gets skipped, and it's the part that pays for itself:

```php
public function messages(): array
{
    return [
        'category_id.exists' => 'The selected category no longer exists.',
        'published_at.after_or_equal' => 'A post cannot be scheduled in the past.',
    ];
}
```

A 422 is not a failure of the API. It's the contract working. But "The selected category id is invalid" sends someone into the debugger, while "The selected category no longer exists" ends the conversation in Slack. The error message is where you decide how long the next bug takes to resolve.

Authorization sits in the same class, at `authorize()`, and it runs before the rules do:

```php
public function authorize(): bool
{
    return $this->user()->can('update', $this->route('post'));
}
```

Inline is fine when the rule is genuinely one line and lives in one place — `return $this->user()->is_admin;`. The moment the same condition appears in a second request class, it belongs in a policy. A policy is a named, testable, reusable object. An `if ($user->id === $post->user_id)` copied into three actions is three places to forget.

What `authorize()` must never be is `return true` "for now." Valid input is not permission, and "for now" is how it ships.

## Stick to the five methods

The controller gets `index`, `store`, `show`, `update`, `destroy`, and nothing else. Type-hint the Form Request so validation and authorization are already done by the time the body runs:

```php
public function store(StorePostRequest $request): JsonResponse
{
    $post = Post::query()->create($request->validated());

    return PostResource::make($post)
        ->response()
        ->setStatusCode(201);
}
```

`$request->validated()` — not `all()`, not `input()`. If a field isn't in the rules, it doesn't reach the model. That plus an explicit `$fillable` is the whole defense against a payload that sets `is_admin`.

When you want a sixth method, you usually want a sixth resource. "Publish a post" isn't a verb on `PostController`; it's `POST /posts/{post}/publication`, or a `PostPublicationController` with a `store`. Naming it as a resource keeps the route a noun and gives the new operation its own Form Request and its own policy check, which is what you actually wanted.

Keep the body thin. Authorize and validate at the edge, do the work, return a Resource. When the query grows past a line or two — filters, search, joins — it moves to a repository the controller injects, so the query is testable without an HTTP request. That's a reuse decision, not a REST one. A repository is not what makes an API RESTful.

## The Resource decides what leaks

`return $post` is the most common accidental disclosure I find. The model is a persistence object. It carries every column in the table, every relation someone eager-loaded for an unrelated reason, and whatever `$hidden` protected until the day someone edited that array. The client then depends on all of it, including the fields you never meant to publish.

A Resource makes the payload an explicit list of keys:

```php
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'title' => $this->title,
        'body' => $this->body,
        'published_at' => $this->published_at,
        'author' => UserResource::make($this->whenLoaded('author')),
    ];
}
```

Two rules I hold to here. Keep the key names the same as the columns unless there's a real reason to differ — a rename feels tidy on day one and becomes a translation layer nobody maintains. And order the payload the way the data is shaped: own attributes first, then loaded relationships.

The rule that matters most is harder. A Resource transforms data. It does not fetch data. `$this->author->name` inside `toArray()` is an N+1 waiting for a list endpoint — one query per row, invisible in a single-record test, fatal at a thousand. That's what `whenLoaded` is for: the relation appears if the controller eager-loaded it, and is omitted if not.

Collections go through the Resource too, so pagination keeps one envelope across the whole API:

```php
public function index(): AnonymousResourceCollection
{
    return PostResource::collection(
        Post::query()->with('author')->latest()->paginate()
    );
}
```

`with('author')` in the controller, `whenLoaded('author')` in the Resource. The controller owns what gets loaded; the Resource owns what gets shown.

## Test the failures first

Write the failing case before the happy path. A test that has never failed hasn't been shown to work, and a happy-path test tells you nothing about the two things that actually break in production: bad input and the wrong caller.

```php
it('rejects a post without a title', function () {
    actingAs(User::factory()->create())
        ->postJson('/api/posts', ['body' => 'no title'])
        ->assertStatus(422)
        ->assertJsonValidationErrors('title');
});

it("forbids editing another user's post", function () {
    $post = Post::factory()->create();

    actingAs(User::factory()->create())
        ->putJson("/api/posts/{$post->id}", ['title' => 'hijacked'])
        ->assertStatus(403);
});
```

Assert the status code, then the shape. 422 for invalid input, 403 when an authenticated caller isn't allowed, 404 only when revealing that the record exists would itself be the leak. Those three numbers are the contract as much as the JSON is.

## What I don't want to see

- `$request->all()` passed into `create()` or `update()`
- `return $post` or `return $post->toArray()` from a controller
- a verb in a route path
- one Form Request handling both create and update
- `authorize()` returning `true` with a comment
- a relationship accessed inside `toArray()` that nothing eager-loaded
- a controller method that isn't one of the five, doing what a new resource should do

None of that is a REST failure in the abstract. Each one is a rule put at the wrong stop — validation in the controller, authorization in the middleware or nowhere, the response shape decided by whatever Eloquent happened to hydrate. Laravel already named the file for each of them. Using the right one every time, and not only when the endpoint feels important, is the entire discipline.
