| name | laravel-conventions |
| description | Opinionated CORE Laravel 13 + Pest 4 conventions, applied by default on any Laravel project — artisan-first file creation, Eloquent/migration/queue/ scheduler patterns, fail-closed multi-tenancy, parallel-safe tests, and a "verified before done" bar. Composes with (never duplicates) maestro's stack-agnostic discipline. Use when / Trigger: editing or creating anything in a Laravel codebase — models, migrations, factories, jobs, scheduled commands, Filament resources, or Pest tests; whenever you reach for a file generator, design a schema/relationship, write a queued job or a tenant-scoped query, or are about to call a Laravel task "done." |
Laravel Conventions
Overview
This is the how-we-build-here layer for Laravel. maestro owns the
stack-agnostic discipline (prove-it, scope-discipline, native-code,
finish-your-turn); this skill owns the Laravel-specific defaults those
disciplines apply to. When they overlap, maestro wins on behavior and this
skill supplies the idiom. Don't restate maestro here — apply it.
Anchor versions: Laravel 13, Pest 4, Filament 5, PHP 8.3+.
Every code-shaped claim below is correct for those versions; if the project
pins older ones, match the project, not this file.
Artisan-first — never hand-create what a generator makes
Hand-written files drift from the framework's current stubs (namespaces,
casts(), return types). Always scaffold, then edit.
- Model + migration + factory in one shot:
php artisan make:model Invoice -mf
(add -c for a controller, --policy, -a for the full set). A model
without a factory is an exception, not the norm.
php artisan make:migration create_invoices_table /
..._add_status_to_invoices_table — let the verb match the change.
php artisan make:factory InvoiceFactory --model=Invoice
php artisan make:seeder, make:job, make:command, make:event,
make:listener, make:notification, make:mail, make:rule,
make:observer, make:policy, make:request, make:cast, make:enum.
php artisan make:test InvoiceTest --pest (unit: --unit) — Pest, not
PHPUnit class stubs.
- Filament 5:
php artisan make:filament-resource Invoice --generate,
make:filament-page, make:filament-widget, make:filament-relation-manager.
- Don't fight a stub you dislike — publish/override it (
vendor:publish --tag=... or a custom stub) so the next generation is right too.
After generating, open the file and write in its style (native-code).
Migrations — order is a correctness property
A migration that adds an FK to a not-yet-created table fails on
migrate:fresh. Migrations run in filename order, which is
timestamp, then alphabetical on ties.
- The referenced table must migrate before the table holding the FK.
make:migration timestamps to the second — two generated in the same
second tie and fall back to alphabetical filename order, which can
invert your intent. After generating related migrations, check the
ordering and rename (bump the timestamp) if parent sorts after child.
- Prefer
$table->foreignIdFor(User::class) /
$table->foreignId('user_id')->constrained() — they encode the reference
and let you chain the delete behavior.
- Choose the delete behavior explicitly, every time:
->cascadeOnDelete() when the child is meaningless without the parent
(order line → order); ->nullOnDelete() (column must be nullable) when the
child outlives the parent (post → author); ->restrictOnDelete() when
deletion should be refused. Never leave it to the DB default.
- Index hot and foreign columns.
constrained() does not add an index
on some drivers — add ->index() on every FK and on any column you filter,
sort, or join by. Composite index for composite lookups; unique constraints
for real uniqueness (and as the DB-level guard behind a unique validation
rule). Indexes are cheap to add now and a migration-on-a-big-table later.
Eloquent
- Factory per model — non-negotiable. Tests and seeders build state
through factories with
state() / named states, never raw inserts.
$fillable (allowlist) over $guarded. Be explicit about
mass-assignable columns.
casts() method (the L11+ form, not the $casts property):
'settings' => 'array', 'published_at' => 'datetime',
'status' => StatusEnum::class, 'price' => MoneyCast::class. Money is
integer cents or a value-object cast — never a float column.
- Type relationships and name them by cardinality:
belongsTo/hasMany/belongsToMany/morphTo with explicit FK args when
they're non-conventional. Return types: BelongsTo, HasMany, etc.
- Guard against N+1: eager-load (
with, load) on the read paths;
consider Model::preventLazyLoading() in non-prod so a lazy load throws
in tests instead of silently shipping.
- Enums are PHP backed enums cast on the model, not loose strings.
Queues & scheduler
- Dev queue driver is
database (QUEUE_CONNECTION=database,
php artisan make:queue-table/queue:table then migrate). Easy to inspect,
fails visibly, no extra service. Sync hides ordering/idempotency bugs.
- Jobs must be idempotent. A job can be redelivered (worker died
after the side effect, before the ack). On entry, re-check state and no-op
if the work is already done — don't assume first delivery. Make external
effects safe to repeat (upsert, conditional charge, dedupe key).
- Schedule safely in
routes/console.php (or the schedule definition):
->withoutOverlapping() so a slow run can't stack on the next tick, and
->onOneServer() when more than one box runs the scheduler. Pick an
explicit cadence; don't rely on default minute alignment for heavy jobs.
- Claim due rows atomically. Releasing/locking work with
SELECT … then UPDATE races two workers onto the same row. Use a single
conditional write — UPDATE … SET locked_by = ?, locked_at = now() WHERE id = ? AND locked_by IS NULL — and act only if it affected a row
(or lockForUpdate() inside a transaction). The winner is the writer, not
the reader.
Multi-tenancy — fail closed (hard-won)
Tenant isolation that depends on every query remembering to filter will
leak. Make isolation the default and the unsafe path the loud one.
BelongsToTenant trait on every tenant-owned model:
- a global scope that constrains all reads to the current tenant;
- a
creating hook that fails closed — if there is no active tenant,
throw, never silently write a null/global tenant_id. A row with no
tenant is a leak waiting to be read by the next tenant.
- Async boundaries carry the tenant explicitly. A job, listener, or
notification runs with no ambient request/tenant. The dispatcher
captures the tenant id onto the payload; the handler sets the tenant from
the carried row and asserts it's present before touching data. Never read
the tenant from request state inside a job — there is none.
- Public / unauthenticated write paths resolve the tenant server-side —
from the route/domain/subdomain/signed token — and never trust a
client-supplied
tenant_id. A form field or JSON key naming the tenant is
an attacker's free pick; ignore it and derive the tenant yourself.
Localization — every string in every locale
A user-facing string is a translation key, never a literal. The default locale
set is en + pt_BR; a key added to one and missing in the other is a bug
(maestro's i18n-completeness rule, in Laravel terms — don't restate it, apply it).
- Strings go through
__() / trans() / @lang, with the key present in
lang/en AND lang/pt_BR. Filament labels use ->translateLabel(), and a
HasLabel enum resolves getLabel() through the same keys — no hardcoded
English in a resource, column, action, or notification title.
- Sweep for parity; don't eyeball it. A key in
lang/en/*.php missing from
lang/pt_BR/*.php (or the reverse) is the common leak — diff the pairs
(array_diff_key recursively) and treat a mismatch as a failure.
- Queued notifications carry the recipient's locale. A job or notification
runs with no ambient request locale, so it renders in the app default
unless you set it:
->locale($notifiable->preferred_locale) (or resolve the
preferred locale on the notifiable) so the user is mailed in their language,
not the server's. Same fail-the-async-boundary reasoning as tenancy above.
Tests — Pest 4, parallel when safe
- New behavior gets a test that actually exercises it (no asserting
true). Feature test for the route/job/command; unit test for pure logic.
use RefreshDatabase; for anything touching the DB — a migrated,
isolated schema per run.
- Run in parallel when it's safe:
php artisan test --parallel
(--recreate-databases after schema changes) or pest --parallel. Parallel
uses a DB per process — drop to serial when tests share global state,
depend on ordering, race on the same fixed unique value, or hit a real
external service. Speed never buys flakiness.
- Prove isolation against raw tables. When asserting a tenant can't see
another tenant's data, query with the scope removed
(
Model::withoutGlobalScope(TenantScope::class) or
DB::table('…')) — asserting through the scoped model can pass while the
data is leaking, a false green. Verify the barrier, not the convenience.
- Fake side effects, don't fire them:
Mail::fake(),
Notification::fake(), Bus::fake()/Queue::fake(), Event::fake(),
Http::fake(), then assert dispatched/sent. Never send real mail or hit a
real queue from a test.
- E2E is Playwright (the in-app, real-browser path), separate from the
Pest suite. Keep Pest for code-level behavior; reach for Playwright only
for genuine end-to-end UI flows.
Symlinked composer packages
If a dependency is a path repository in composer.json (symlinked into
vendor/ from a sibling folder), it is source you own, not a frozen
vendor dir.
- Make the change in the package's own repo folder, not under
vendor/
(editing vendor/ edits the symlink target and will be lost on the next
composer install/CI).
- Commit and push in the package repo, retag if the consumer pins a
version, then re-verify the consumer (
composer update <pkg> if needed,
re-run the consumer's suite). The change isn't done until the consuming app
is green against it. (This is maestro's cross-project rule, in Laravel
terms.)
Verified before done
Don't claim a Laravel task complete on "written" or "should work" (prove-it).
The done bar is three things green, this session:
php artisan migrate:fresh (or migrate:fresh --seed) succeeds — the
schema builds clean from zero, ordering and FKs included.
- The test suite passes (
php artisan test, parallel where safe).
- The app boots —
php artisan about / the route or page actually
renders / the command runs — no fatal on a real request.
If any of the three is red, the task is in progress, and you say so in
those words.
Live-app UI verification (when the change has a UI)
Done-bar #3 ("the page renders") is proven in a real browser, not by a
compiling Blade/Livewire file (prove-it; maestro's render-verify). When the
change touches a screen, run the recipe:
- Serve with command-line env overrides — never by editing
.env. If the
local stack isn't reachable (Redis, etc.), override on the command:
CACHE_STORE=file SESSION_DRIVER=file QUEUE_CONNECTION=sync php artisan serve
— the committed .env stays untouched, and you clean up only what you started.
- Log in as a seeded user, pulling the credentials from the seeder via
php artisan tinker rather than guessing or hardcoding them.
- Drive it with Playwright MCP: navigate the actual flow and screenshot the
page.
- Assert it is actually clean — zero browser-console errors and a clean
tail of
storage/logs/laravel.log for that request. A page that "renders"
while logging a 500 is not verified.
- Count what should be there — assert the expected number of rows/cards/
items rendered, and that a filter actually changes the count, not merely that
the response was 200.