Purpose and how to read this guide
This guide defines how Laravel 13+ applications in Project are written, structured, tested and reviewed. It is generic: swap Project for your application name and the Invoice examples for your own domain. It covers both supported frontend stacks — Livewire 4 + Flux UI and Inertia v3 + React — and calls out where the two differ.
The guiding idea is the same one Laravel 13 leans into: declare, don't configure. Wherever the framework offers a PHP attribute for something (model tables, fillable fields, job retries, command signatures, controller middleware), we use the attribute instead of a protected property or a method override. Configuration lives on the class declaration where a reader sees it first.
Rule levels
| Level | Meaning |
|---|---|
| MUST / MUST NOT | Required. CI or review blocks the change. |
| SHOULD / SHOULD NOT | The default. Deviate only with a short comment explaining why. |
| MAY | Allowed when it helps; no justification needed. |
| AVOID | Legal but discouraged; expect a review question. |
Principles
- Readable over clever. Code is read far more than it is written.
- Thin edges, rich core. Controllers, Livewire components and commands stay thin; business rules live in actions and models.
- Let the tools enforce it. Anything Pint, Larastan or Rector can check is not debated in review.
- Small pieces. Small classes, small components, small pull requests.
- Framework first. Reach for a Laravel feature before a package, and a package before custom infrastructure.
Baseline and toolchain
| Area | Standard |
|---|---|
| Framework | Laravel 13.x (released 17 March 2026; bug fixes to Q3 2027, security fixes to 17 March 2028) |
| PHP | 8.5 minimum (the newest version Laravel 13 supports) |
| Database | PostgreSQL 17+ (MySQL 8.4+ allowed); UUIDv7 primary keys |
| Frontend A | Livewire 4 + Flux UI Pro + Tailwind CSS 4 |
| Frontend B | Inertia v3 + React 19 + TypeScript (strict) + Tailwind CSS 4 |
| Build | Vite |
| Tests | Pest 4 (with the browser plugin for end-to-end tests) |
| Formatting | Laravel Pint (laravel preset + project rules) |
| Static analysis | Larastan at level 8 minimum |
| Refactoring | Rector with the Laravel set |
| Queues | Redis via Horizon in production; database driver locally |
| Local dev | Laravel Herd or Sail; composer dev runs server, queue, logs and Vite together |
Rules
- MUSTLARAVEL-02.1 pin
"php": "^8.5"and"laravel/framework": "^13.0"incomposer.jsonand commitcomposer.lock. - MUSTLARAVEL-02.2 use
declare(strict_types=1);at the top of every PHP file inapp/,database/,routes/andtests/. - MUSTLARAVEL-02.3 keep the Laravel 11+ slim skeleton: middleware, exceptions and routing are configured in
bootstrap/app.php, not in aKernelclass. - SHOULDLARAVEL-02.4 follow each Laravel upgrade guide within one minor release; Laravel 13 has minimal breaking changes, so stay current.
- MUST NOTLARAVEL-02.5 add a package that duplicates a first-party feature (for example, a separate UUID package —
HasUuidsalready generates UUIDv7). - MAYLARAVEL-02.6 use first-party packages freely: Horizon, Pulse, Reverb, Scout, Sanctum, Socialite, Pennant, Cashier, the AI SDK and Boost.
Project layout
Start from the default Laravel 13 skeleton and add folders only when they earn their keep. Group by type first, then by domain inside each type folder once a folder passes about 15 files.
app/
Actions/ # One class per use case: CreateInvoice, SendInvoice
Invoices/
Data/ # Readonly DTOs passed between layers
Enums/ # Backed enums: InvoiceStatus
Events/ Listeners/
Exceptions/ # Domain exceptions: InvoiceAlreadyPaid
Http/
Controllers/
Requests/ # Form requests
Resources/ # API resources
Jobs/
Livewire/ # Livewire stack: class-based components only (rare)
Models/
Concerns/ # Model traits: Invoice/HasInvoiceRelationships.php
Builders/ # Custom Eloquent builders
Collections/ # Custom collections
Observers/
Policies/
Providers/
Queries/ # Reusable read-side query objects
bootstrap/app.php # Routing, middleware, exceptions
config/
database/
factories/ migrations/ seeders/
resources/
css/app.css
views/ # Blade layouts, Blade components, Livewire SFCs
components/ # Blade + Livewire SFCs (⚡invoice-table.blade.php)
layouts/ # layouts:: namespace
pages/ # pages:: full-page Livewire components
js/ # Inertia stack only
pages/ # One file per Inertia page: invoices/index.tsx
components/ # Small, reusable React components
layouts/
hooks/
types/ # Generated + hand-written TS types
routes/
web.php api.php console.php
tests/
Feature/ Unit/ Browser/
Rules
- MUSTLARAVEL-03.1 keep one class per file, with the file named after the class.
- MUSTLARAVEL-03.2 mirror
app/insidetests/Featureandtests/Unitso each class's tests are easy to find (app/Actions/Invoices/CreateInvoice.php→tests/Unit/Actions/Invoices/CreateInvoiceTest.php). - MUST NOTLARAVEL-03.3 create
Helpers,Utils,MiscorCommondumping-ground folders. - SHOULDLARAVEL-03.4 split a model's relationships, scopes and accessors into traits under
app/Models/Concerns/<Model>/once the model grows past about 150 lines (see §7). - SHOULDLARAVEL-03.5 keep
routes/web.phpshort by grouping withRoute::controller()andRoute::prefix(); split intoroutes/web/*.phpfiles only past about 150 lines. - AVOIDLARAVEL-03.6 deep module systems (
Modules/Billing/...) unless the team is large enough to own modules independently.
Naming
| Thing | Convention | Example |
|---|---|---|
| Model | Singular PascalCase | Invoice, InvoiceLine |
| Table | Plural snake_case | invoices, invoice_lines |
| Pivot table | Singular models, alphabetical | customer_tag |
| Column | snake_case | due_date, paid_at |
| Foreign key | <model>_id |
customer_id |
| Boolean column | is_ / has_ prefix |
is_archived |
| Timestamp column | _at suffix |
sent_at |
| Controller | Singular + Controller |
InvoiceController |
| Invokable controller | Verb phrase + Controller |
SendInvoiceController |
| Action | Verb phrase | CreateInvoice, MarkInvoicePaid |
| Form request | Verb + model + Request |
StoreInvoiceRequest |
| API resource | Model + Resource |
InvoiceResource |
| Job | Imperative verb phrase | GenerateInvoicePdf |
| Event | Past tense | InvoicePaid |
| Listener | What it does | SendPaymentReceipt |
| Policy | Model + Policy |
InvoicePolicy |
| Enum | Singular PascalCase, PascalCase cases | InvoiceStatus::Overdue |
| Model trait | Has + model + concern |
HasInvoiceRelationships |
| Livewire component | kebab-case view / PascalCase class | invoices.create-invoice |
| Inertia page | kebab-case file under pages/ |
pages/invoices/show.tsx |
| React component | PascalCase file and export | InvoiceStatusBadge.tsx |
| Route name | Plural resource + dot action | invoices.show |
| URL | Plural kebab-case | /invoices/{invoice}/line-items |
| Config key | snake_case | billing.default_currency |
| Variable / method | camelCase | $dueDate, markPaid() |
| Constant | UPPER_SNAKE | MAX_LINES |
Rules
- MUSTLARAVEL-04.1 name methods for what they do from the caller's view:
$invoice->markPaid(), not$invoice->updateStatusToPaid(). - MUSTLARAVEL-04.2 name booleans so they read as a question:
$isOverdue,canBeSent(). - MUST NOTLARAVEL-04.3 use abbreviations other than well-known ones (
id,url,pdf,api). - SHOULDLARAVEL-04.4 name collections in the plural and single items in the singular:
$invoices,$invoice. - AVOIDLARAVEL-04.5 suffixing classes with their pattern when the folder already says it (
Actions\CreateInvoice, notCreateInvoiceAction).
Formatting and static analysis
Formatting is never a review topic: Pint decides, CI enforces.
Formatting
- MUSTLARAVEL-05.1 run Laravel Pint with the committed
pint.json(Appendix A). CI runspint --testand fails on any diff. - MUSTLARAVEL-05.2 use 4-space indentation in PHP (PSR-12, which Pint enforces) and 2-space indentation in Blade, TypeScript, CSS, JSON and YAML (
.editorconfigin Appendix A). - SHOULDLARAVEL-05.3 keep lines under 120 characters; break long method chains one call per line.
- MUSTLARAVEL-05.4 use short array syntax, trailing commas in multi-line arrays, arguments and parameters, and single quotes unless interpolating.
- MUSTLARAVEL-05.5 order class members: constants, properties, constructor, public methods, protected methods, private methods. Attributes sit directly above the element they configure, one attribute per line.
- MUSTLARAVEL-05.6 import every class with
use; no fully qualified names inline.
Static analysis
- MUSTLARAVEL-05.7 run Larastan at level 8 or higher (Appendix B). New code adds no baseline entries; the baseline only shrinks.
- MUSTLARAVEL-05.8 give every parameter, property and return a native type. Use PHPDoc only for what PHP cannot express: generics (
Collection<int, Invoice>), array shapes (array{id: string, total: int}) and@property-readfor model columns. - MUST NOTLARAVEL-05.9 use
@phpstan-ignorewithout a trailing reason:// @phpstan-ignore argument.type (vendor stub is wrong). - SHOULDLARAVEL-05.10 run Rector with the Laravel and PHP 8.5 sets in dry-run mode in CI; apply its changes in separate refactor commits.
One command
- MUSTLARAVEL-05.11 expose
composer lint(Pint, Larastan, Rector dry run) andcomposer test(Pest in parallel). Both run in CI on every pull request (Appendix D).
Modern PHP usage
Classes
- MUSTLARAVEL-06.1 declare classes
finalby default. Removefinalonly when a class is designed for extension (models extended by a package, abstract bases). - MUSTLARAVEL-06.2 use constructor property promotion and
readonlyfor injected dependencies and DTOs; a DTO is afinal readonly class. - SHOULDLARAVEL-06.3 use PHP 8.4 asymmetric visibility (
public private(set)) instead of a getter for a value that is read publicly but written only internally. - MAYLARAVEL-06.4 use PHP 8.4 property hooks on plain objects; MUST NOT use them on Eloquent models (they bypass Eloquent's attribute system — use casts and accessors there).
- SHOULDLARAVEL-06.5 chain on
newwithout parentheses (PHP 8.4):new InvoiceNumber($raw)->formatted().
Types and values
- MUSTLARAVEL-06.6 use backed enums for every fixed set of values (statuses, types, roles) and cast model columns to them. Put labels and colours on the enum as methods.
- MUSTLARAVEL-06.7 use
matchinstead ofswitch; let an unhandled case throwUnhandledMatchErrorrather than adding a silent default. - MUSTLARAVEL-06.8 use strict comparison (
===,in_array(..., strict: true)). - SHOULDLARAVEL-06.9 use named arguments when a call has a boolean or more than three positional arguments:
Number::currency($total, in: 'EUR'). - MUSTLARAVEL-06.10 store money as integer minor units (cents) or with
brick/money; MUST NOT use floats for money. - SHOULDLARAVEL-06.11 use
CarbonImmutable(set withDate::use(CarbonImmutable::class)in a service provider).
Functions and flow
- SHOULDLARAVEL-06.12 return early instead of nesting
if/else. - SHOULDLARAVEL-06.13 use first-class callable syntax:
array_map($this->format(...), $lines). - MUST NOTLARAVEL-06.14 use
compact(),extract(), variable variables or@error suppression. - AVOIDLARAVEL-06.15 static helper classes; prefer an injected service or a method on the model or value object.
- SHOULDLARAVEL-06.16 use Laravel helpers (
str(),Str,Arr,Number,collect()) over raw PHP string and array functions when they read more clearly.
PHP 8.5 features
PHP 8.5 is the minimum, so these are available everywhere:
- SHOULDLARAVEL-06.17 use the pipe operator
|>for short linear transformations of one value:$slug = $title |> trim(...) |> Str::slug(...);. AVOID pipes longer than four steps or with side effects; use a collection or a named method instead. - SHOULDLARAVEL-06.18 use
clone($dto, ['dueAt' => $newDate])(clone with) to derive a modified copy of a readonly DTO or value object instead of writingwith*()methods by hand. - SHOULDLARAVEL-06.19 mark methods whose return value must not be ignored (results, new immutable instances) with
#[\NoDiscard]. - SHOULDLARAVEL-06.20 use
array_first()/array_last()overreset()/end()or$array[array_key_first($array)]. - MAYLARAVEL-06.21 use attributes on constants and
#[\Override]on properties; MUST use#[\Override]on every method that overrides a parent or implements an interface method outside the framework's magic hooks.
enum InvoiceStatus: string
{
case Draft = 'draft';
case Sent = 'sent';
case Paid = 'paid';
case Overdue = 'overdue';
public function label(): string
{
return match ($this) {
self::Draft => __('Draft'),
self::Sent => __('Sent'),
self::Paid => __('Paid'),
self::Overdue => __('Overdue'),
};
}
public function isOpen(): bool
{
return in_array($this, [self::Sent, self::Overdue], strict: true);
}
}Eloquent models: attributes for everything
Laravel 13 lets a model declare its configuration with PHP attributes instead of protected properties. Every model in Project uses attributes for everything an attribute exists for. A reader sees the table, mass-assignment rules, serialization, observers, factory, policy and collection at the top of the class, before any code.
Property → attribute map
All attributes below live in Illuminate\Database\Eloquent\Attributes unless noted.
| Instead of | Use |
|---|---|
$table, $primaryKey, $keyType, $incrementing, $timestamps, $dateFormat |
#[Table('invoices', key: ..., keyType: ..., incrementing: ..., timestamps: ..., dateFormat: ...)] |
$incrementing = false |
#[WithoutIncrementing] |
$timestamps = false |
#[WithoutTimestamps] |
$connection |
#[Connection('reporting')] |
$fillable |
#[Fillable([...])] |
$guarded / unguard() |
#[Guarded([...])] / #[Unguarded] (not allowed, see rules) |
$hidden / $visible |
#[Hidden([...])] / #[Visible([...])] |
$appends |
#[Appends([...])] |
$touches |
#[Touches(['customer'])] |
refresh() after every save |
#[Refreshes] |
scopeOverdue() naming convention |
#[Scope] on a protected method |
static::addGlobalScope() in booted() |
#[ScopedBy([TenantScope::class])] |
static::observe() in a provider |
#[ObservedBy([InvoiceObserver::class])] |
newCollection() override |
#[CollectedBy(InvoiceCollection::class)] |
newFactory() / factory guessing |
#[UseFactory(InvoiceFactory::class)] |
newEloquentBuilder() override |
#[UseEloquentBuilder(InvoiceBuilder::class)] |
Gate::policy() / policy guessing |
#[UsePolicy(InvoicePolicy::class)] |
toResource() guessing |
#[UseResource(InvoiceResource::class)], #[UseResourceCollection(...)] |
bootHasX() / initializeHasX() trait naming |
#[Boot] / #[Initialize] on the trait method |
Three things still have no attribute and stay as code: casts (the casts() method), default values ($attributes) and $dispatchesEvents. Keep those directly below the use statements.
Rules
- MUSTLARAVEL-07.1 configure every model through attributes wherever one exists. MUST NOT declare
$table,$fillable,$guarded,$hidden,$visible,$appends,$touches,$connection,$primaryKey,$keyType,$incrementingor$timestampsproperties. - MUSTLARAVEL-07.2 list mass-assignable columns with
#[Fillable]. MUST NOT use#[Unguarded]or an empty#[Guarded([])]. - MUSTLARAVEL-07.3 declare
#[Table]explicitly even when it matches the convention, so the table name is greppable. - MUSTLARAVEL-07.4 wire the factory, policy and observers with
#[UseFactory],#[UsePolicy]and#[ObservedBy]rather than relying on name guessing or provider registration. - MUSTLARAVEL-07.5 hide secrets and internal columns with
#[Hidden]. - MUSTLARAVEL-07.6 define local scopes with
#[Scope]on aprotectedmethod that takesBuilder $queryand returnsvoid. MUST NOT use the oldscopeXprefix. - MUSTLARAVEL-07.7 write casts in the
casts()method and cast every enum, date, boolean, JSON and encrypted column. - SHOULDLARAVEL-07.8 order attributes: table and connection, mass assignment, serialization, relations (
Touches), scopes, wiring (ObservedBy,CollectedBy,UseFactory,UseEloquentBuilder,UsePolicy,UseResource). - MUSTLARAVEL-07.9 declare models
final.
UUIDv7 primary keys
- MUSTLARAVEL-07.10 give every model a UUID primary key through the
HasUuidstrait. In Laravel 13,HasUuidsgenerates ordered UUIDv7 values, which index well and sort by creation time. No customnewUniqueId()is needed. - MUSTLARAVEL-07.11 create the column with
$table->uuid('id')->primary()and foreign keys withforeignUuid()(§8). - MUST NOTLARAVEL-07.12 expose integer IDs anywhere; route model binding uses the UUID.
- MAYLARAVEL-07.13 override
uniqueIds()to also generate a UUID for another column, such as a publicreference.
Split models with traits
Models grow. Keep the class file about configuration and behaviour, and move the rest into traits under app/Models/Concerns/<Model>/.
| Trait | Holds |
|---|---|
Has<Model>Relationships |
All relationship methods |
Has<Model>Scopes |
#[Scope] methods (when there are more than three) |
Has<Model>Accessors |
Accessors and mutators (Attribute::make) |
Has<Model>Behaviour |
Optional: domain methods such as markPaid() once they outgrow the class |
- MUSTLARAVEL-07.14 move relationships into a
Has<Model>Relationshipstrait for every model with more than two relationships. - MUSTLARAVEL-07.15 give every relationship method a return type with generics in the PHPDoc:
/** @return BelongsTo<Customer, $this> */. - SHOULDLARAVEL-07.16 mark trait boot logic with
#[Boot]/#[Initialize]instead of relying onbootTraitName()naming. - MUST NOTLARAVEL-07.17 put queries that belong to other models, HTTP concerns or notifications in a model trait.
The model
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\InvoiceStatus;
use App\Http\Resources\InvoiceResource;
use App\Models\Builders\InvoiceBuilder;
use App\Models\Collections\InvoiceCollection;
use App\Models\Concerns\Invoice\HasInvoiceAccessors;
use App\Models\Concerns\Invoice\HasInvoiceRelationships;
use App\Models\Scopes\TenantScope;
use App\Observers\InvoiceObserver;
use App\Policies\InvoicePolicy;
use Database\Factories\InvoiceFactory;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\CollectedBy;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Attributes\Touches;
use Illuminate\Database\Eloquent\Attributes\UseEloquentBuilder;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Illuminate\Database\Eloquent\Attributes\UsePolicy;
use Illuminate\Database\Eloquent\Attributes\UseResource;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Table('invoices')]
#[Fillable(['customer_id', 'number', 'status', 'issued_at', 'due_at', 'paid_at', 'total_cents', 'currency', 'notes'])]
#[Hidden(['internal_notes'])]
#[Appends(['is_overdue'])]
#[Touches(['customer'])]
#[ScopedBy([TenantScope::class])]
#[ObservedBy([InvoiceObserver::class])]
#[CollectedBy(InvoiceCollection::class)]
#[UseFactory(InvoiceFactory::class)]
#[UseEloquentBuilder(InvoiceBuilder::class)]
#[UsePolicy(InvoicePolicy::class)]
#[UseResource(InvoiceResource::class)]
final class Invoice extends Model
{
/** @use HasFactory<InvoiceFactory> */
use HasFactory;
use HasInvoiceAccessors;
use HasInvoiceRelationships;
use HasUuids;
use SoftDeletes;
/** @var array<string, mixed> */
protected $attributes = [
'status' => InvoiceStatus::Draft,
'currency' => 'EUR',
];
protected function casts(): array
{
return [
'status' => InvoiceStatus::class,
'issued_at' => 'immutable_date',
'due_at' => 'immutable_date',
'paid_at' => 'immutable_datetime',
'total_cents' => 'integer',
'internal_notes' => 'encrypted',
];
}
public function markPaid(): void
{
$this->update([
'status' => InvoiceStatus::Paid,
'paid_at' => now(),
]);
}
#[Scope]
protected function overdue(Builder $query): void
{
$query->where('status', InvoiceStatus::Sent)
->whereDate('due_at', '<', today());
}
}
The relationships trait
<?php
declare(strict_types=1);
namespace App\Models\Concerns\Invoice;
use App\Models\Customer;
use App\Models\InvoiceLine;
use App\Models\Payment;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
trait HasInvoiceRelationships
{
/** @return BelongsTo<Customer, $this> */
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
/** @return HasMany<InvoiceLine, $this> */
public function lines(): HasMany
{
return $this->hasMany(InvoiceLine::class)->orderBy('position');
}
/** @return HasMany<Payment, $this> */
public function payments(): HasMany
{
return $this->hasMany(Payment::class);
}
}
Pivot models
Pivot models follow the same rules: #[Table('customer_tag')], #[WithoutTimestamps] when the pivot has none, and HasUuids when the pivot has its own id.
Migrations, factories and seeders
Migrations
- MUSTLARAVEL-08.1 use anonymous migration classes (
return new class extends Migration). - MUSTLARAVEL-08.2 use
$table->uuid('id')->primary()for primary keys and$table->foreignUuid('customer_id')->constrained()for foreign keys. - MUSTLARAVEL-08.3 declare an explicit
onDeletebehaviour on every foreign key (cascadeOnDelete(),restrictOnDelete()ornullOnDelete()). Pick on purpose; there is no default answer. - MUSTLARAVEL-08.4 index every column used in a
where,orderByor join that runs on a hot path, and add composite indexes in the order the query filters. - MUST NOTLARAVEL-08.5 edit a migration that has run in any shared environment. Write a new one.
- SHOULDLARAVEL-08.6 write a working
down()method; when a change cannot be reversed safely, throw an exception indown()that says so. - MUST NOTLARAVEL-08.7 use models in migrations. Use the query builder for data fixes, or move the fix into a one-off command.
- SHOULDLARAVEL-08.8 split schema changes and data backfills into separate migrations, and run large backfills as queued jobs.
- SHOULDLARAVEL-08.9 store money as
unsignedBigInteger('total_cents')with achar('currency', 3)column, and enums asstringcolumns (not native database enums). - SHOULDLARAVEL-08.10 use
$table->timestampsTz()/softDeletesTz()on PostgreSQL.
return new class extends Migration
{
public function up(): void
{
Schema::create('invoices', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('customer_id')->constrained()->restrictOnDelete();
$table->string('number')->unique();
$table->string('status')->index();
$table->date('issued_at')->nullable();
$table->date('due_at')->nullable();
$table->timestampTz('paid_at')->nullable();
$table->unsignedBigInteger('total_cents')->default(0);
$table->char('currency', 3);
$table->text('notes')->nullable();
$table->text('internal_notes')->nullable();
$table->timestampsTz();
$table->softDeletesTz();
$table->index(['customer_id', 'status', 'due_at']);
});
}
public function down(): void
{
Schema::dropIfExists('invoices');
}
};
Factories
- MUSTLARAVEL-08.11 have a factory for every model, bound to it with
#[UseModel(Invoice::class)](Illuminate\Database\Eloquent\Factories\Attributes) instead of a$modelproperty. - MUSTLARAVEL-08.12 produce a valid, saveable model from
definition()with no extra state. - SHOULDLARAVEL-08.13 express variations as named states:
->paid(),->overdue(),->forCustomer($customer). - MUST NOTLARAVEL-08.14 hit external services or the filesystem from a factory.
#[UseModel(Invoice::class)]
final class InvoiceFactory extends Factory
{
public function definition(): array
{
return [
'customer_id' => Customer::factory(),
'number' => fake()->unique()->numerify('INV-#####'),
'status' => InvoiceStatus::Draft,
'issued_at' => now(),
'due_at' => now()->addDays(30),
'total_cents' => fake()->numberBetween(1_000, 500_000),
'currency' => 'EUR',
];
}
public function overdue(): self
{
return $this->state([
'status' => InvoiceStatus::Sent,
'due_at' => now()->subDays(5),
]);
}
}
Seeders
- MUSTLARAVEL-08.15 keep
DatabaseSeederrunnable on an empty database to give a usable local environment in one command (php artisan migrate:fresh --seed). - MUST NOTLARAVEL-08.16 seed production with demo data; production reference data goes in a migration or a dedicated, idempotent seeder.
Queries and performance
Strict mode
- MUSTLARAVEL-09.1 enable strict mode outside production in
AppServiceProvider::boot():Model::shouldBeStrict(! app()->isProduction()). This throws on lazy loading, on silently discarded fillable attributes and on missing attributes. - MUSTLARAVEL-09.2 log lazy-loading violations in production instead of throwing:
Model::handleLazyLoadingViolationUsing(...).
Writing queries
- MUSTLARAVEL-09.3 eager load every relation a view, resource or Inertia page uses:
Invoice::with(['customer', 'lines']). - SHOULDLARAVEL-09.4 use
withCount(),withSum()andwithExists()instead of loading a relation just to count or sum it. - MUSTLARAVEL-09.5 select only the columns you need on large tables and in exports.
- MUSTLARAVEL-09.6 use
chunkById(),lazyById()orcursor()to iterate over more than about 1,000 rows. MUST NOT call->get()or->all()on an unbounded table. - MUSTLARAVEL-09.7 paginate every list endpoint and screen; prefer
cursorPaginate()for infinite scroll and APIs. - SHOULDLARAVEL-09.8 move reusable query logic into
#[Scope]methods or a custom builder bound with#[UseEloquentBuilder]. A query used in more than one place with more than three clauses becomes a scope, builder method orapp/Queriesclass. - MUST NOTLARAVEL-09.9 build SQL by concatenating input. Use bindings;
DB::raw()andwhereRaw()take only constants or bound values. - MUSTLARAVEL-09.10 wrap multi-write operations in
DB::transaction(), and dispatch side effects with->afterCommit(). - SHOULDLARAVEL-09.11 use
upsert()orinsert()for bulk writes instead of saving models in a loop (remember that bulk writes skip model events). - MAYLARAVEL-09.12 use
whereVectorSimilarTo()(Laravel 13, PostgreSQL + pgvector) for semantic search; keep embeddings in their own column and index.
Caching
- SHOULDLARAVEL-09.13 cache expensive reads with
Cache::flexible()(stale-while-revalidate) and tagged keys; useCache::touch()to extend a TTL without re-reading the value. - MUSTLARAVEL-09.14 namespace cache keys by model and ID:
invoice:{id}:totals. - MUSTLARAVEL-09.15 clear or update caches from the observer or action that changes the data, never from a controller.
A custom builder
/** @extends Builder<Invoice> */
final class InvoiceBuilder extends Builder
{
public function forCustomer(Customer $customer): self
{
return $this->whereBelongsTo($customer);
}
public function open(): self
{
return $this->whereIn('status', [InvoiceStatus::Sent, InvoiceStatus::Overdue]);
}
}
// Usage
$invoices = Invoice::query()
->forCustomer($customer)
->open()
->with('customer')
->withSum('payments', 'amount_cents')
->latest('issued_at')
->paginate(25);Routing and controllers
Routes
- MUSTLARAVEL-10.1 name every route, and generate URLs with
route()(or Wayfinder / Ziggy on the Inertia stack). MUST NOT hard-code paths. - MUSTLARAVEL-10.2 use resource routes (
Route::resource(),Route::apiResource()) with->only()for CRUD, and invokable controllers for single actions. - MUSTLARAVEL-10.3 use route model binding with the UUID key; scope nested bindings with
->scopeBindings(). - MUST NOTLARAVEL-10.4 put closures with logic in route files; closures may only return a view.
- SHOULDLARAVEL-10.5 rate-limit authentication, password reset and public API routes with named limiters defined in
AppServiceProvider.
Route::middleware(['auth', 'verified'])->group(function (): void {
Route::resource('invoices', InvoiceController::class)->only(['index', 'show', 'store', 'update']);
Route::post('invoices/{invoice}/send', SendInvoiceController::class)->name('invoices.send');
});
Controllers: attributes, not constructors
Laravel 13 lets controllers declare middleware and authorization with attributes from Illuminate\Routing\Attributes\Controllers.
- MUSTLARAVEL-10.6 declare controller middleware with
#[Middleware]on the class or method, usingonly:/except:where needed. MUST NOT implementHasMiddlewareor call$this->middleware(). - SHOULDLARAVEL-10.7 declare per-action authorization with
#[Authorize]instead of aGate::authorize()call at the top of the method. - MUSTLARAVEL-10.8 keep controllers thin: validate (form request), authorize, call one action, return a response. Target fewer than 15 lines per method.
- MUST NOTLARAVEL-10.9 query or write models directly in a controller beyond a simple read for
index/show. - MUSTLARAVEL-10.10 return typed responses:
View,Response(Inertia),RedirectResponse,JsonResource.
#[Middleware('auth')]
#[Middleware('throttle:invoices', only: ['store', 'update'])]
final class InvoiceController
{
#[Authorize('viewAny', Invoice::class)]
public function index(): InertiaResponse
{
return Inertia::render('invoices/index', [
'invoices' => InvoiceResource::collection(
Invoice::query()->with('customer')->latest()->paginate(25),
),
]);
}
#[Authorize('create', Invoice::class)]
public function store(StoreInvoiceRequest $request, CreateInvoice $createInvoice): RedirectResponse
{
$invoice = $createInvoice->handle($request->user(), $request->toData());
return to_route('invoices.show', $invoice)->with('status', __('Invoice created.'));
}
}
Livewire vs Inertia routing
| Livewire stack | Inertia stack | |
|---|---|---|
| Full-page screen | Route::livewire('/invoices', 'pages::invoices.index') — the component is the controller |
Controller method returns Inertia::render('invoices/index', $props) |
| Form submit | Component method (save()) calls an action |
POST to a controller that calls an action and redirects |
| Simple static page | Route::view() |
Route::inertia('/about', 'about') |
- MUSTLARAVEL-10.11 keep the same rule on both stacks: the entry point (component or controller) validates, authorizes and delegates to an action.
Validation and form requests
- MUSTLARAVEL-11.1 validate every write in a form request (controllers) or a Livewire form object (Livewire). MUST NOT call
$request->validate()inline in a controller. - MUSTLARAVEL-11.2 configure form-request behaviour with attributes from
Illuminate\Foundation\Http\Attributes:#[StopOnFirstFailure],#[ErrorBag('invoice')],#[RedirectTo('/invoices')]or#[RedirectToRoute('invoices.index')]. MUST NOT set the matching$stopOnFirstFailure,$errorBag,$redirector$redirectRouteproperties. - MUSTLARAVEL-11.3 write rules as arrays, not pipe strings, and use rule objects (
Rule::enum(),Rule::unique(),Password::defaults(),File::types()). - MUSTLARAVEL-11.4 read validated data only:
$request->validated(),$request->safe(), or a typed DTO built from them. MUST NOT pass$request->all()to a model. - SHOULDLARAVEL-11.5 expose a
toData()method that turns validated input into a readonly DTO for the action. - SHOULDLARAVEL-11.6 put authorization in the policy and leave
authorize()out of the form request (it defaults totruewhen absent), so authorization lives in one place. - SHOULDLARAVEL-11.7 write custom rules as invokable rule classes in
app/Rules; AVOID long closure rules. - MUSTLARAVEL-11.8 translate messages through
lang/files; no hard-coded English inmessages().
#[StopOnFirstFailure]
#[ErrorBag('invoice')]
final class StoreInvoiceRequest extends FormRequest
{
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'customer_id' => ['required', 'uuid', Rule::exists('customers', 'id')],
'due_at' => ['required', 'date', 'after_or_equal:today'],
'currency' => ['required', 'string', 'size:3'],
'lines' => ['required', 'array', 'min:1', 'max:200'],
'lines.*.description' => ['required', 'string', 'max:255'],
'lines.*.quantity' => ['required', 'integer', 'min:1'],
'lines.*.unit_price_cents' => ['required', 'integer', 'min:0'],
];
}
public function toData(): CreateInvoiceData
{
return CreateInvoiceData::fromArray($this->validated());
}
}
On each frontend stack
- Livewire: MUSTLARAVEL-11.9 validate with a form object (
Livewire\Form) using#[Validate]attributes on its properties, or arules()method for dynamic rules. Call$this->form->validate()before handing data to an action. - Inertia: MUSTLARAVEL-11.10 rely on the form request; validation errors arrive in
useForm().errors(or the<Form>component'serrors) automatically. MUST NOT duplicate server rules as the only check on the client; client-side checks are for convenience only.
Actions, services and DTOs
Business logic lives in actions: small classes that do one use case. The same action serves a controller, a Livewire component, a job, a command and a test, so both frontend stacks share one core.
Actions
- MUSTLARAVEL-13.1 give each action one public method,
handle(), with typed parameters and a typed return. - MUSTLARAVEL-13.2 inject dependencies through the constructor; resolve actions from the container (method injection or
app()), never withnew. - MUSTLARAVEL-13.3 wrap multi-step writes in
DB::transaction()inside the action. - SHOULDLARAVEL-13.4 compose actions by injecting one into another, not by inheritance.
- MUST NOTLARAVEL-13.5 read the request, session or
auth()inside an action; pass the user and data in.
final readonly class CreateInvoice
{
public function __construct(
private NextInvoiceNumber $nextNumber,
) {}
public function handle(User $user, CreateInvoiceData $data): Invoice
{
return DB::transaction(function () use ($user, $data): Invoice {
$invoice = Invoice::create([
'customer_id' => $data->customerId,
'number' => $this->nextNumber->for($user->team),
'due_at' => $data->dueAt,
'currency' => $data->currency,
'total_cents' => $data->totalCents(),
]);
$invoice->lines()->createMany($data->linesForInsert());
InvoiceCreated::dispatch($invoice);
return $invoice;
});
}
}
DTOs
- MUSTLARAVEL-13.6 pass structured data between layers as
final readonly classDTOs inapp/Data, not as arrays. - SHOULDLARAVEL-13.7 give each DTO a named constructor (
fromArray(),fromRequest()) and keep validation out of it (the form request already did that). - MAYLARAVEL-13.8 use
spatie/laravel-datawhen DTOs also need to serialize to TypeScript for the Inertia stack; keep the same naming.
Services and container attributes
- SHOULDLARAVEL-13.9 use a service class only for an integration with state or configuration (a payment gateway client, a PDF renderer). Business rules still go in actions.
- MUSTLARAVEL-13.10 bind interfaces to implementations with class attributes —
#[Bind(StripeGateway::class)]on the interface,#[Singleton]or#[Scoped]on the class — instead of registering them in a service provider, when the binding is static. - SHOULDLARAVEL-13.11 inject configuration and framework services with parameter attributes from
Illuminate\Container\Attributes:#[Config('billing.currency')] string $currency,#[Storage('invoices')] Filesystem $disk,#[Log('billing')] LoggerInterface $log,#[CurrentUser] User $user,#[Tag('exporters')] iterable $exporters. - MUST NOTLARAVEL-13.12 call
config(),Storage::disk()orauth()inside a constructor when a container attribute can inject the value.
#[Bind(StripePaymentGateway::class)]
interface PaymentGateway
{
public function charge(Invoice $invoice): PaymentResult;
}
#[Singleton]
final readonly class StripePaymentGateway implements PaymentGateway
{
public function __construct(
#[Config('services.stripe.secret')] private string $secret,
#[Log('billing')] private LoggerInterface $log,
) {}
// ...
}Frontend: Livewire or Inertia
Project uses one of two stacks per application. Pick one at the start and do not mix them in the same app.
| Livewire stack | Inertia stack | |
|---|---|---|
| Versions | Livewire 4 + Flux UI Pro + Tailwind 4 | Inertia v3 + React 19 + TypeScript + Tailwind 4 |
| Choose when | The team is PHP-first; screens are forms, tables and dashboards | Rich client interactions, a design system in React, or shared code with a React Native / SPA client |
| State lives | On the server, in the component | In React, with props from the server |
| Data contract | Public properties and computed values | Props built from API resources, typed in TypeScript |
Shared rules (both stacks)
- MUSTLARAVEL-14.1 keep UI components small: one job per component, roughly under 150 lines of template. Split a screen into a page plus focused child components (
InvoiceTable,InvoiceFilters,InvoiceStatusBadge). - MUSTLARAVEL-14.2 keep business logic out of the view layer: components call actions; they never write models directly with complex rules.
- MUSTLARAVEL-14.3 localise every user-facing string (
__()in PHP/Blade, a translation helper in React). - MUSTLARAVEL-14.4 meet WCAG 2.2 AA: labelled inputs, visible focus, keyboard-reachable actions, colour contrast.
- SHOULDLARAVEL-14.5 share layouts: one app layout, one guest layout.
Livewire 4
Components
- SHOULDLARAVEL-14.6 write components as single-file components (
resources/views/components/⚡invoice-table.blade.php), the Livewire 4 default. Switch to a multi-file component (--mfc) once the PHP part passes about 100 lines, and keep the colocated*.test.phpfile there. - MUSTLARAVEL-14.7 put full-page components in the
pages::namespace and route them withRoute::livewire(). - MUSTLARAVEL-14.8 use Flux UI components (
<flux:input>,<flux:button>,<flux:table>,<flux:modal>) before writing custom markup; wrap repeated Flux combinations in an anonymous Blade component. - SHOULDLARAVEL-14.9 use plain Blade components (no Livewire) for anything without server state: badges, cards, empty states.
State and security
- MUSTLARAVEL-14.10 treat every public property as user input: anyone can change it from the browser. Mark IDs and anything the user must not change with
#[Locked]. - MUSTLARAVEL-14.11 store models in properties (Livewire locks the model ID) or as
#[Locked]UUIDs, never a raw editable ID. - MUSTLARAVEL-14.12 authorize inside every action method (
$this->authorize('update', $this->invoice)), not only inmount(). - MUSTLARAVEL-14.13 move form fields into a
Livewire\Formobject with#[Validate]attributes when a form has more than three fields. - SHOULDLARAVEL-14.14 use
#[Computed]for derived data (queries, totals) instead of storing it in public properties; this keeps the payload small and the data fresh. - SHOULDLARAVEL-14.15 use
#[Url]for filters, search and sort so screens are linkable. - SHOULDLARAVEL-14.16 listen for events with
#[On('invoice-paid')]; AVOID long chains of component-to-component events — lift the state up or use a shared parent.
Performance
- MUSTLARAVEL-14.17 default to
wire:model(sync on next request); usewire:model.live.debounce.300msonly where instant feedback matters (search). - SHOULDLARAVEL-14.18 wrap independently refreshing parts of a page in
@islandblocks (withlazy: truefor slow widgets) instead of splitting into many nested components. - SHOULDLARAVEL-14.19 mark slow components lazy (
#[Lazy]orlazyon the tag) and provide an@placeholder. - SHOULDLARAVEL-14.20 use the automatic
data-loadingattribute with Tailwind variants for loading states instead of hand-writtenwire:loadingblocks. - MAYLARAVEL-14.21 use
wire:sortfor drag-and-drop ordering andwire:transitionfor page transitions.
<?php
use App\Actions\Invoices\MarkInvoicePaid;
use App\Models\Invoice;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
new class extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Computed]
public function invoices()
{
return Invoice::query()
->with('customer')
->when($this->search, fn ($q) => $q->whereLike('number', "%{$this->search}%"))
->latest('issued_at')
->paginate(25);
}
public function markPaid(Invoice $invoice, MarkInvoicePaid $markPaid): void
{
$this->authorize('update', $invoice);
$markPaid->handle($invoice);
}
};
?>
<div class="space-y-4">
<flux:input wire:model.live.debounce.300ms="search" icon="magnifying-glass" :placeholder="__('Search invoices')" />
<flux:table :paginate="$this->invoices">
@foreach ($this->invoices as $invoice)
<x-invoices.row :$invoice wire:key="{{ $invoice->id }}" />
@endforeach
</flux:table>
</div>
Inertia v3 + React
Pages and components
- MUSTLARAVEL-14.22 write one page per file under
resources/js/pages, matching theInertia::render()name (invoices/show→pages/invoices/show.tsx). - MUSTLARAVEL-14.23 keep pages thin: they receive props and compose small components from
resources/js/components. - MUSTLARAVEL-14.24 use TypeScript in strict mode; MUST NOT use
any. Type page props with an interface per page. - SHOULDLARAVEL-14.25 generate route helpers and TypeScript types from PHP (Wayfinder for routes and actions;
spatie/laravel-typescript-transformeror laravel-data for DTOs and enums) instead of hand-writing them. - SHOULDLARAVEL-14.26 use persistent layouts and
useLayoutProps/setLayoutProps(v3) for layout state such as page titles and breadcrumbs, instead of context or event buses.
Props and data
- MUSTLARAVEL-14.27 build props from API resources (
InvoiceResource::make($invoice)), never pass raw models — everything in a prop is visible in the page source. - MUSTLARAVEL-14.28 keep shared data in
HandleInertiaRequests::share()minimal (auth user summary, flash, abilities) and lazy (fn () => ...). - SHOULDLARAVEL-14.29 use
Inertia::defer()for slow, non-critical props andInertia::optional()for props loaded only on partial reloads; useInertia::merge()/Inertia::scroll()for infinite lists. - SHOULDLARAVEL-14.30 use partial reloads (
router.reload({ only: ['invoices'] })) instead of full visits when only one prop changes.
Forms and requests
- MUSTLARAVEL-14.31 submit forms with the
<Form>component oruseForm, and showerrorsfrom the server next to each field. - SHOULDLARAVEL-14.32 use v3 optimistic updates (
form.optimistic(...),router.optimistic(...)) for quick toggles and list changes; Inertia rolls them back on a non-2xx response. - MUSTLARAVEL-14.33 use
useHttp(v3's built-in client) for JSON endpoints that do not return an Inertia response. MUST NOT add Axios unless you need its interceptors. - MUSTLARAVEL-14.34 redirect after a successful
POST/PUT/DELETE; MUST NOT returnInertia::render()from a write route.
import { Form, Head } from '@inertiajs/react';
import { update } from '@/actions/App/Http/Controllers/InvoiceController';
import { InvoiceStatusBadge } from '@/components/invoices/InvoiceStatusBadge';
import { Button, Field } from '@/components/ui';
import type { Invoice } from '@/types/generated';
interface Props {
invoice: Invoice;
can: { update: boolean };
}
export default function ShowInvoice({ invoice, can }: Props) {
return (
<>
<Head title={invoice.number} />
<header className="flex items-center gap-3">
<h1 className="text-xl font-semibold">{invoice.number}</h1>
<InvoiceStatusBadge status={invoice.status} />
</header>
{can.update && (
<Form {...update.form(invoice.id)}>
{({ errors, processing }) => (
<>
<Field name="due_at" type="date" defaultValue={invoice.due_at} error={errors.due_at} />
<Button type="submit" disabled={processing}>Save</Button>
</>
)}
</Form>
)}
</>
);
}
Inertia setup
- MUSTLARAVEL-14.35 install the
@inertiajs/viteplugin and callcreateInertiaApp()withstrictMode: true. - SHOULDLARAVEL-14.36 enable SSR for public, indexable pages (
vite build --ssr+php artisan inertia:start-ssr); authenticated app screens MAY skip it.
APIs and resources
- MUSTLARAVEL-15.1 return every API response (and every Inertia prop built from a model) through an API resource. MUST NOT return a model or
toArray()directly. - MUSTLARAVEL-15.2 configure resources with attributes from
Illuminate\Http\Resources\Attributes:#[Collects(InvoiceResource::class)]on a resource collection,#[PreserveKeys]when keys matter. MUST NOT set the$collectsor$preserveKeysproperties. - SHOULDLARAVEL-15.3 use Laravel 13's first-party JSON:API resources for public or partner APIs, so filtering, sparse fieldsets, includes and links follow the spec without custom code. Internal APIs MAY use plain resources.
- MUSTLARAVEL-15.4 use
whenLoaded(),whenCounted()andwhenAggregated()so a resource never triggers a query. - MUSTLARAVEL-15.5 format money, dates and enums consistently: money as integer minor units plus currency, dates as ISO 8601 in UTC, enums as their backing value.
- MUSTLARAVEL-15.6 version public APIs in the URL (
/api/v1/...) and keep a version stable once published. - MUSTLARAVEL-15.7 authenticate APIs with Sanctum (first-party SPA cookies or tokens with abilities) and rate-limit every route.
- MUSTLARAVEL-15.8 return errors in one shape (
message+errors, or JSON:APIerrors) with correct status codes: 401, 403, 404, 409, 422, 429. - SHOULDLARAVEL-15.9 document public APIs with an OpenAPI spec generated from code (for example Scramble) and check it in CI.
final class InvoiceResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'number' => $this->number,
'status' => $this->status,
'status_label' => $this->status->label(),
'due_at' => $this->due_at?->toDateString(),
'total' => ['amount' => $this->total_cents, 'currency' => $this->currency],
'is_overdue' => $this->is_overdue,
'customer' => CustomerResource::make($this->whenLoaded('customer')),
'lines_count' => $this->whenCounted('lines'),
];
}
}
#[Collects(InvoiceResource::class)]
final class InvoiceResourceCollection extends ResourceCollection {}Jobs, events, queues and commands
Jobs: attributes for everything
Laravel 13 job attributes live in Illuminate\Queue\Attributes.
| Instead of | Use |
|---|---|
$tries |
#[Tries(3)] |
$timeout |
#[Timeout(120)] |
$backoff / backoff() |
#[Backoff([10, 60, 300])] |
$maxExceptions |
#[MaxExceptions(2)] |
$failOnTimeout |
#[FailOnTimeout] |
$deleteWhenMissingModels |
#[DeleteWhenMissingModels] |
$uniqueFor |
#[UniqueFor(3600)] |
onQueue() in the constructor |
#[Queue('billing')] |
onConnection() |
#[Connection('redis')] |
withoutRelations() on each model |
#[WithoutRelations] |
- MUSTLARAVEL-16.1 configure retries, timeouts, backoff, queue and connection with these attributes. MUST NOT declare the matching properties.
- MUSTLARAVEL-16.2 set
#[Tries]and#[Timeout]on every job, and keep the timeout below the queue'sretry_after. - MUSTLARAVEL-16.3 make jobs idempotent: running twice must not charge twice or send two emails. Use
ShouldBeUnique, a guard on the model state, or an idempotency key. - MUSTLARAVEL-16.4 pass models or IDs, never large arrays or closures; add
#[WithoutRelations]so queued payloads stay small. - MUSTLARAVEL-16.5 implement
failed()for jobs whose failure a person must hear about. - SHOULDLARAVEL-16.6 use
Queue::route()(Laravel 13) in a service provider to route job classes to queues centrally when many jobs share a queue; use#[Queue]for one-off placement. - SHOULDLARAVEL-16.7 dispatch after commit (
->afterCommit()orafter_commitin config) when the job reads data written in the same transaction. - SHOULDLARAVEL-16.8 use
Bus::batch()for fan-out work with progress, andBus::chain()for ordered steps.
#[Queue('billing')]
#[Tries(5)]
#[Timeout(120)]
#[Backoff([10, 60, 300])]
#[DeleteWhenMissingModels]
#[WithoutRelations]
final class GenerateInvoicePdf implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public function __construct(public Invoice $invoice) {}
public function uniqueId(): string
{
return $this->invoice->id;
}
public function handle(RenderInvoicePdf $render): void
{
$render->handle($this->invoice);
}
}
Events and listeners
- MUSTLARAVEL-16.9 name events in the past tense and keep them as plain data carriers (public readonly properties).
- SHOULDLARAVEL-16.10 make listeners that do I/O queued (
ShouldQueue), and rely on event discovery instead of registering listeners by hand. - AVOIDLARAVEL-16.11 using model events (
creating,saved) for business workflows. Use them in observers for model housekeeping only (setting a number, clearing a cache); dispatch domain events from actions. - SHOULDLARAVEL-16.12 broadcast real-time updates with Reverb; on Livewire listen with
#[On('echo:...')], on Inertia withuseEchofrom@laravel/echo-react.
Console commands
- MUSTLARAVEL-16.13 declare commands with attributes from
Illuminate\Console\Attributes:#[Signature('invoices:remind {--days=3}')],#[Description('...')], and#[Help],#[Usage]or#[Hidden]as needed. MUST NOT set$signatureor$description. - MUSTLARAVEL-16.14 keep commands thin: parse input, call an action, report the result. Return
self::SUCCESS/self::FAILURE. - MUSTLARAVEL-16.15 schedule tasks in
routes/console.phpwith->withoutOverlapping()and->onOneServer()for anything that must not run twice.
#[Signature('invoices:remind {--days=3 : Days before the due date}')]
#[Description('Send reminders for invoices that are due soon.')]
final class RemindDueInvoices extends Command
{
public function handle(SendDueReminders $sendReminders): int
{
$count = $sendReminders->handle(days: (int) $this->option('days'));
$this->components->info("Queued {$count} reminders.");
return self::SUCCESS;
}
}Configuration and environment
- MUSTLARAVEL-17.1 call
env()only insideconfig/*.php. Everywhere else readconfig()or inject with#[Config('billing.currency')];env()returnsnullonce config is cached. - MUSTLARAVEL-17.2 keep
.env.examplecomplete and current, with safe placeholder values and a comment for anything unusual. - MUST NOTLARAVEL-17.3 commit
.envor any secret. Production secrets live in the host's secret store or an encrypted.env.production.encrypted(php artisan env:encrypt). - SHOULDLARAVEL-17.4 group app-specific settings in their own files (
config/billing.php) rather than adding keys toconfig/app.php. - MUSTLARAVEL-17.5 run
php artisan optimizeon deploy (config, routes, views, events cached). - SHOULDLARAVEL-17.6 use Pennant feature flags for gradual rollouts instead of config booleans that need a deploy.
- MUSTLARAVEL-17.7 keep timezone
UTCin config and the database; convert to the user's timezone only for display.
Errors, logging and observability
Exceptions
- MUSTLARAVEL-18.1 throw domain exceptions (
InvoiceAlreadyPaid extends DomainException) for broken business rules, and give each a clear message. - SHOULDLARAVEL-18.2 let an exception render itself (
render()returning a response) or register its rendering inbootstrap/app.phpwith->withExceptions(). MUST NOT catch an exception only to rethrow or ignore it. - MUSTLARAVEL-18.3 catch exceptions only where you can recover or add context; otherwise let them reach the handler.
- MUST NOTLARAVEL-18.4 leak stack traces or SQL to users:
APP_DEBUG=falseeverywhere except local. - SHOULDLARAVEL-18.5 throttle noisy exceptions with
->throttle()inwithExceptions()and stop reporting expected ones with->dontReport().
Logging
- MUSTLARAVEL-18.6 log with context arrays, not interpolated strings:
Log::info('Invoice sent', ['invoice_id' => $invoice->id]). - SHOULDLARAVEL-18.7 add request-wide context with the
Contextfacade (request ID, user ID, tenant); it flows into logs and queued jobs automatically. - MUST NOTLARAVEL-18.8 log secrets, tokens, passwords, full card data or personal data beyond IDs.
- SHOULDLARAVEL-18.9 use dedicated channels for noisy areas (
#[Log('billing')]) and JSON formatting in production.
Observability
- SHOULDLARAVEL-18.10 run Pulse in production for slow queries, slow jobs, exceptions and usage, and Horizon for queue health.
- SHOULDLARAVEL-18.11 send exceptions to an error tracker (Nightwatch, Sentry or Flare) with release and environment tags.
- MUSTLARAVEL-18.12 expose the built-in
/uphealth route and monitor it.
Security
- MUSTLARAVEL-19.1 keep Laravel 13's
PreventRequestForgerymiddleware (origin-aware CSRF protection) enabled for all web routes; exclude a route only for signed webhooks, and verify the signature. - MUSTLARAVEL-19.2 mass-assign only through
#[Fillable]lists and validated data (§7, §11). - MUSTLARAVEL-19.3 escape output:
{{ }}in Blade.{!! !!}is allowed only for HTML sanitised on the server, with a comment saying where. In React, MUST NOT usedangerouslySetInnerHTMLwithout the same sanitising. - MUSTLARAVEL-19.4 authorize every route, Livewire action and job that acts for a user (§12), and scope queries to the current tenant with a global scope (
#[ScopedBy]). - MUSTLARAVEL-19.5 use signed URLs (
URL::temporarySignedRoute()) for links in emails and downloads. - MUSTLARAVEL-19.6 store uploads on a private disk and serve them through an authorized route or temporary URL; validate type and size with
File::types()->max(). - MUSTLARAVEL-19.7 hash passwords with the default hasher and enforce
Password::defaults()set in a provider (min length, mixed case, uncompromised in production). - MUSTLARAVEL-19.8 cast secrets and personal data columns as
encryptedwhere the app does not need to query them. - MUSTLARAVEL-19.9 set security headers (CSP, HSTS,
X-Frame-Options,Referrer-Policy) through middleware. - MUSTLARAVEL-19.10 run
composer auditandnpm auditin CI and fix high or critical issues before release. - MUST NOTLARAVEL-19.11 expose Telescope, Horizon, Pulse or debug tools in production without an authorization gate.
Testing with Pest
What to test
| Layer | Test type | Location |
|---|---|---|
| Actions, value objects, enums, DTOs | Unit | tests/Unit/... |
| Controllers, form requests, policies, jobs, commands | Feature (HTTP / dispatch) | tests/Feature/... |
| Livewire components | Livewire feature test (Livewire::test()) |
Next to an MFC component, or tests/Feature/Livewire/... |
| Inertia pages | Feature test with assertInertia() |
tests/Feature/... |
| Critical journeys (sign-up, checkout, pay invoice) | Browser (Pest 4 browser plugin) | tests/Browser/... |
| React components (Inertia stack) | Vitest + Testing Library | Next to the component: InvoiceStatusBadge.test.tsx |
Rules
- MUSTLARAVEL-20.1 write tests with Pest, using
it()/test()descriptions that read as sentences. - MUSTLARAVEL-20.2 cover every action, policy and form request; a bug fix starts with a failing test.
- MUSTLARAVEL-20.3 use
RefreshDatabase(orLazilyRefreshDatabase) and factories. MUST NOT depend on seeded data or on test order. - MUSTLARAVEL-20.4 configure test classes and hooks with attributes from
Illuminate\Foundation\Testing\Attributes, such as#[Seed]/#[Seeder(BillingSeeder::class)]instead of$seed/$seederproperties, and#[SetUp]/#[TearDown]on hook methods in reusable testing traits. - MUSTLARAVEL-20.5 fake the outside world:
Http::fake()withHttp::preventStrayRequests(),Queue::fake(),Mail::fake(),Notification::fake(),Storage::fake(),Event::fake()(only the events you assert). - SHOULDLARAVEL-20.6 freeze time with
$this->travelTo()/freezeTime()when dates matter. - MUSTLARAVEL-20.7 run tests in parallel in CI (
pest --parallel) and keep the suite under five minutes. - SHOULDLARAVEL-20.8 add architecture tests with Pest's
arch()to enforce this guide: strict types, final classes, noenv()outside config, no debugging functions. - SHOULDLARAVEL-20.9 keep coverage of
app/Actionsandapp/Policiesabove 90% and the whole app above 80%.
it('marks an overdue invoice as paid', function (): void {
$user = User::factory()->create();
$invoice = Invoice::factory()->for(Customer::factory()->for($user->team))->overdue()->create();
$this->actingAs($user)
->post(route('invoices.pay', $invoice))
->assertRedirect(route('invoices.show', $invoice));
expect($invoice->refresh())
->status->toBe(InvoiceStatus::Paid)
->paid_at->not->toBeNull();
});
it('hides other teams\' invoices', function (): void {
$invoice = Invoice::factory()->create();
$this->actingAs(User::factory()->create())
->get(route('invoices.show', $invoice))
->assertNotFound();
});
arch('app code is strict and final')
->expect('App')
->toUseStrictTypes()
->classes()->toBeFinal()->ignoring('App\\Providers');
arch('no debugging calls')
->expect(['dd', 'dump', 'ray', 'var_dump'])
->not->toBeUsed();
Testing each frontend stack
// Livewire
Livewire::actingAs($user)
->test('invoice-table')
->set('search', 'INV-001')
->assertSee('INV-001')
->call('markPaid', $invoice->id)
->assertHasNoErrors();
// Inertia
$this->actingAs($user)
->get(route('invoices.show', $invoice))
->assertInertia(fn (AssertableInertia $page) => $page
->component('invoices/show')
->where('invoice.id', $invoice->id)
->where('can.update', true));Git, commits and code review
Commits
- MUSTLARAVEL-21.1 follow Conventional Commits:
type(scope): summaryin the imperative, lower case, no trailing period, under 72 characters. - Types:
feat,fix,refactor,perf,test,docs,build,ci,chore,style,revert. Scope is the domain or area:feat(invoices): add overdue reminders. - MUSTLARAVEL-21.2 mark breaking changes with
!and aBREAKING CHANGE:footer. - SHOULDLARAVEL-21.3 explain the why in the body when the change is not obvious from the summary.
- MUSTLARAVEL-21.4 keep each commit building and passing tests; squash fix-up commits before merging.
Branches and pull requests
- MUSTLARAVEL-21.5 branch from
mainwith a short typed name:feat/invoice-reminders,fix/overdue-scope. - SHOULDLARAVEL-21.6 keep pull requests under about 400 changed lines (excluding generated files and lock files); split larger work behind a Pennant flag.
- MUSTLARAVEL-21.7 pass
composer lint,composer testand the frontend checks (npm run lint,npm run types,npm run test) before review. - MUSTLARAVEL-21.8 include migrations, factories and tests in the same pull request as the feature they support.
- MUSTLARAVEL-21.9 describe what changed, why, how it was tested, and any deploy steps (migrations, config, queue restarts).
Review checklist
- Does every model use attributes instead of properties,
HasUuids, and traits once it grows? - Are writes validated by a form request or Livewire form, and authorized by a policy?
- Is business logic in an action, with controllers and components thin?
- Are queries eager loaded, paginated and indexed?
- Are jobs idempotent with
#[Tries]and#[Timeout]? - Are new UI pieces small components, accessible and translated?
- Are there tests for the happy path, the permission failure and the validation failure?
Appendix
A. pint.json and .editorconfig
{
"preset": "laravel",
"rules": {
"declare_strict_types": true,
"final_class": true,
"strict_comparison": true,
"fully_qualified_strict_types": true,
"global_namespace_import": { "import_classes": true, "import_constants": true, "import_functions": true },
"ordered_class_elements": {
"order": ["use_trait", "case", "constant", "property", "construct", "method_public", "method_protected", "method_private"]
},
"ordered_imports": { "sort_algorithm": "alpha", "imports_order": ["class", "function", "const"] },
"trailing_comma_in_multiline": { "elements": ["arrays", "arguments", "parameters", "match"] },
"mb_str_functions": true
}
}
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.php]
indent_size = 4
[*.md]
trim_trailing_whitespace = false
B. phpstan.neon and rector.php
includes:
- vendor/larastan/larastan/extension.neon
- phpstan-baseline.neon
parameters:
level: 8
paths:
- app
- config
- database
- routes
checkMissingCallableSignature: true
treatPhpDocTypesAsCertain: false
return RectorConfig::configure()
->withPaths([__DIR__.'/app', __DIR__.'/database', __DIR__.'/routes', __DIR__.'/tests'])
->withPhpSets(php85: true)
->withSets([LaravelSetList::LARAVEL_130])
->withPreparedSets(deadCode: true, codeQuality: true, typeDeclarations: true, earlyReturn: true);
C. New model checklist
- Migration with
uuid('id')->primary(),foreignUuid()keys with explicitonDelete, and indexes for hot queries. -
finalmodel withHasUuidsandHasFactory. -
#[Table]and#[Fillable]declared;#[Hidden]for secrets;#[Appends]only for cheap accessors. -
#[UseFactory],#[UsePolicy],#[ObservedBy](if any),#[CollectedBy]/#[UseEloquentBuilder]/#[UseResource]where used. -
casts()covers every enum, date, boolean, money and encrypted column. - Scopes use
#[Scope]on protected methods. - Relationships in
Has<Model>Relationshipsonce there are more than two, each with a generic return type. - Factory with
#[UseModel]and named states; policy; resource; tests.
D. Composer scripts
{
"scripts": {
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
],
"lint": [
"pint --test",
"phpstan analyse --memory-limit=2G",
"rector --dry-run"
],
"fix": [
"rector",
"pint"
],
"test": [
"@php artisan config:clear --ansi",
"pest --parallel"
]
}
}