Laravel scaffolding
The code is the easy part. What gets forgotten is the wiring: the route, the policy, the binding, the
test — each of which fails in its own quiet way.
The conventions the generated code must follow are in padosoft-laravel-conventions; the checks it must
survive are in padosoft-laravel-security-review.
0. Ask first, in one batch
- what: endpoint, controller, service, CRUD, job, test;
- which domain/module it belongs to — and whether that module exists;
- who may call it: guest, authenticated, a role, an owner. This answer decides the policy, and it is the
one most often left implicit;
- the shape of input and output, and whether the response is shared with a frontend that already expects
a shape;
- synchronous or queued, and if queued, on which connection and queue.
If the answer to "who may call it" is "the authenticated user", ask the follow-up: any authenticated user,
or only the owner of the record? That distinction is the whole of SEC-IDOR-001.
1. Endpoint / controller — the file list
| # |
File |
Skipping it means |
| 1 |
The route, in the right route file and group |
404, or worse: the route exists without the group's middleware |
| 2 |
FormRequest |
Validation lives in the controller, or nowhere |
| 3 |
DTO |
An untyped array crosses every boundary |
| 4 |
Controller |
— |
| 5 |
Service / action |
The logic is in the controller and is not testable without HTTP |
| 6 |
Policy + authorize() called |
A policy that exists and is never invoked is not a control |
| 7 |
JsonResource / response shape |
The model is serialised whole, including what should not leave |
| 8 |
Feature test, happy and failure path |
— |
| 9 |
Route list check |
The route answers on the verb and path you think |
Steps 1, 6 and 9 are the ones that fail silently: everything compiles, the endpoint answers, and the
authorisation was never asked.
php artisan route:list --path=<fragment> # verb, path, middleware, action
Read the middleware column: that is where you see whether auth actually applies.
2. Service
final class DoTheThing
{
public function __construct(private readonly ThingRepository $repo) {}
public function __invoke(DoTheThingData $data): Thing
{
// one responsibility; no HTTP, no request(), no auth() in here
}
}
- No
request(), no auth(), no session() inside a service. Whatever it needs arrives as an argument,
or it cannot be called from a command, a job or a test.
- Register it in the container only if it needs binding; otherwise let autowiring do it.
- A service returning
bool to mean "it worked" is usually hiding a failure the caller needs — see
padosoft-failure-visibility.
3. CRUD backend
Beyond the endpoint list: for each of index / show / store / update / destroy, decide who may — they are
rarely the same answer — and whether destroy is a soft delete. If it is, every listing and count elsewhere
needs to know (padosoft-laravel-conventions §4).
For the listing: pagination from the start, not "for now it returns everything". The dataset that makes it a
problem arrives in production, not in the fixture.
4. Queued job
php artisan queue:work --queue=<name> # does the queue you dispatched to actually have a worker?
- The job orchestrates, the service implements.
- Set
$tries, $backoff and $timeout deliberately; the defaults are a decision you did not make.
failed() handles the permanent failure — and that is a place people leave empty, so the failure becomes
a row nobody reads.
- Dispatch after commit if the job reads a row the current transaction is writing.
- Retryable vs permanent: fail explicitly on a permanent error rather than throwing something the queue
retries sixteen times.
5. Test
A test written together with the code, not after: see padosoft-test-integrity for what makes a test
real. The minimum here:
- the happy path;
- the authorisation path — a user who must not, gets a 403;
- one failure path, actually provoked;
- database state restored between tests, and your own teardown before the framework's.
Before declaring it done
php artisan route:list --path=<fragment> # route + middleware
grep -rn "authorize(" app/Http/Controllers/<Controller>.php
php artisan test --filter=<TestClass>
vendor/bin/pint --test && vendor/bin/phpstan analyse # or the project's equivalents
Gotchas
- A route outside the right group loses the group's middleware — auth included — and still answers 200.
- A policy is not a control until
authorize() is called. Registering it is not enough.
- A
FormRequest that the controller ignores (reading all() instead of validated()) validates and
then hands over the unvalidated payload anyway.
- A job dispatched to a queue with no worker is a silent hole: everything returns 202, nothing runs.
- Do not invent the layout. Read where the neighbouring modules put their services, requests and routes
before creating files: this list is the shape, the repository has the paths.
Checklist
Final report
Created: <what> · module: <where>
Files: route · request · dto · controller · service · policy · resource · test
Authorisation: <who may, and where it is enforced>
Queue: <name> · tries/backoff/timeout · worker verified: yes|no
route:list middleware: <what it shows>
Pint / static analysis / tests: PASS | FAIL <detail>
1---2name: padosoft-laravel-scaffolding3description: Use this skill when adding something new to a Laravel application — an endpoint, a controller, a service, a CRUD backend, a queued job, a test — "make me the API for X", "add the CRUD for Y", "I need a service that does Z" — and whenever a piece added earlier is half-wired: a route that answers 404, a policy that is never called, a request class that validates nothing because the controller reads `all()`, a job that is dispatched but has no queue configured. It lists the files each piece touches, in order. Do not use it to review existing code (padosoft-laravel-conventions) or for the security review.4license: MIT5---67# Laravel scaffolding89The code is the easy part. What gets forgotten is the **wiring**: the route, the policy, the binding, the10test — each of which fails in its own quiet way.1112The conventions the generated code must follow are in **`padosoft-laravel-conventions`**; the checks it must13survive are in **`padosoft-laravel-security-review`**.1415---1617## 0. Ask first, in one batch1819- **what**: endpoint, controller, service, CRUD, job, test;20- **which domain/module** it belongs to — and whether that module exists;21- **who may call it**: guest, authenticated, a role, an owner. This answer decides the policy, and it is the22 one most often left implicit;23- **the shape of input and output**, and whether the response is shared with a frontend that already expects24 a shape;25- **synchronous or queued**, and if queued, on which connection and queue.2627If the answer to "who may call it" is "the authenticated user", ask the follow-up: *any* authenticated user,28or only the owner of the record? That distinction is the whole of `SEC-IDOR-001`.2930## 1. Endpoint / controller — the file list3132| # | File | Skipping it means |33|---|---|---|34| 1 | The route, in the right route file and group | 404, or worse: the route exists without the group's middleware |35| 2 | `FormRequest` | Validation lives in the controller, or nowhere |36| 3 | DTO | An untyped array crosses every boundary |37| 4 | Controller | — |38| 5 | Service / action | The logic is in the controller and is not testable without HTTP |39| 6 | Policy + `authorize()` **called** | A policy that exists and is never invoked is not a control |40| 7 | `JsonResource` / response shape | The model is serialised whole, including what should not leave |41| 8 | Feature test, happy **and** failure path | — |42| 9 | Route list check | The route answers on the verb and path you think |4344Steps 1, 6 and 9 are the ones that fail silently: everything compiles, the endpoint answers, and the45authorisation was never asked.4647```bash48php artisan route:list --path=<fragment> # verb, path, middleware, action49```5051Read the **middleware column**: that is where you see whether auth actually applies.5253## 2. Service5455```php56final class DoTheThing57{58 public function __construct(private readonly ThingRepository $repo) {}5960 public function __invoke(DoTheThingData $data): Thing61 {62 // one responsibility; no HTTP, no request(), no auth() in here63 }64}65```6667- **No `request()`, no `auth()`, no `session()` inside a service.** Whatever it needs arrives as an argument,68 or it cannot be called from a command, a job or a test.69- Register it in the container only if it needs binding; otherwise let autowiring do it.70- A service returning `bool` to mean "it worked" is usually hiding a failure the caller needs — see71 **`padosoft-failure-visibility`**.7273## 3. CRUD backend7475Beyond the endpoint list: for each of index / show / store / update / destroy, decide **who may** — they are76rarely the same answer — and whether destroy is a soft delete. If it is, every listing and count elsewhere77needs to know (`padosoft-laravel-conventions` §4).7879For the listing: pagination from the start, not "for now it returns everything". The dataset that makes it a80problem arrives in production, not in the fixture.8182## 4. Queued job8384```bash85php artisan queue:work --queue=<name> # does the queue you dispatched to actually have a worker?86```8788- The job orchestrates, the service implements.89- Set `$tries`, `$backoff` and `$timeout` deliberately; the defaults are a decision you did not make.90- `failed()` handles the permanent failure — and that is a place people leave empty, so the failure becomes91 a row nobody reads.92- Dispatch **after commit** if the job reads a row the current transaction is writing.93- Retryable vs permanent: fail explicitly on a permanent error rather than throwing something the queue94 retries sixteen times.9596## 5. Test9798A test written together with the code, not after: see **`padosoft-test-integrity`** for what makes a test99real. The minimum here:100101- the happy path;102- **the authorisation path** — a user who must not, gets a 403;103- **one failure path**, actually provoked;104- database state restored between tests, and your own teardown before the framework's.105106---107108## Before declaring it done109110```bash111php artisan route:list --path=<fragment> # route + middleware112grep -rn "authorize(" app/Http/Controllers/<Controller>.php113php artisan test --filter=<TestClass>114vendor/bin/pint --test && vendor/bin/phpstan analyse # or the project's equivalents115```116117## Gotchas118119- **A route outside the right group** loses the group's middleware — auth included — and still answers 200.120- **A policy is not a control until `authorize()` is called.** Registering it is not enough.121- **A `FormRequest` that the controller ignores** (reading `all()` instead of `validated()`) validates and122 then hands over the unvalidated payload anyway.123- **A job dispatched to a queue with no worker** is a silent hole: everything returns 202, nothing runs.124- **Do not invent the layout.** Read where the neighbouring modules put their services, requests and routes125 before creating files: this list is the shape, the repository has the paths.126127## Checklist128129- [ ] Route in the right file **and group**; `route:list` shows the expected middleware130- [ ] FormRequest, and the controller reads `validated()` / the DTO — never `all()`131- [ ] Logic in the service; no `request()`/`auth()` inside it132- [ ] Policy written **and** `authorize()` called on the path133- [ ] Response through a resource, not the raw model134- [ ] Job: tries, backoff, timeout, `failed()`, after-commit dispatch if needed, worker on that queue135- [ ] Tests: happy, authorisation, one real failure path136- [ ] Pint and static analysis clean137138## Final report139140```141Created: <what> · module: <where>142Files: route · request · dto · controller · service · policy · resource · test143Authorisation: <who may, and where it is enforced>144Queue: <name> · tries/backoff/timeout · worker verified: yes|no145route:list middleware: <what it shows>146Pint / static analysis / tests: PASS | FAIL <detail>147```