Regenerate Boost guidelines and skills

Generated by boost:update for Boost 2.8, which replaces the pest-testing
skill with testing-best-practices and adds infer-conventions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XYnWFt9pJEwvAmNFN38XD
This commit is contained in:
Andreas Reinhold / reini
2026-09-10 10:42:22 +02:00
co-authored by Claude Opus 5
parent 3638455167
commit 92b3b3de56
38 changed files with 1521 additions and 1001 deletions
@@ -1,8 +1,8 @@
# Advanced Query Patterns
# Advanced Query Best Practices
## Use `addSelect()` Subqueries for Single Values from Has-Many
## Select Single Relationship Values with Subqueries
Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries.
When only one value from a has-many relationship is needed, consider a correlated subquery with `addSelect()` instead of loading the entire relationship. This selects the value as part of the main query without an additional relationship query.
```php
public function scopeWithLastLoginAt($query): void
@@ -16,14 +16,14 @@ public function scopeWithLastLoginAt($query): void
}
```
## Create Dynamic Relationships via Subquery FK
## Create Dynamic Relationships with a Subquery Foreign Key
Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection.
The same pattern can select a foreign key and expose the selected model through a `belongsTo` relationship. Eager loading that relationship still executes a separate query, but it avoids loading the full has-many collection.
```php
public function lastLogin(): BelongsTo
{
return $this->belongsTo(Login::class);
return $this->belongsTo(Login::class, 'last_login_id');
}
public function scopeWithLastLogin($query): void
@@ -37,9 +37,9 @@ public function scopeWithLastLogin($query): void
}
```
## Use Conditional Aggregates Instead of Multiple Count Queries
## Combine Related Counts with Conditional Aggregates
Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values.
Combine several counts over the same filtered data set into one query by using conditional aggregates. Use `toBase()` when only scalar values are needed and model hydration provides no benefit. Confirm the expression syntax against the application's database engine.
```php
$statuses = Feature::toBase()
@@ -49,50 +49,50 @@ $statuses = Feature::toBase()
->first();
```
## Use `setRelation()` to Prevent Circular N+1
## Reuse Loaded Parent Models with `setRelation()`
When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries.
When a parent and its children are already loaded and code also accesses `$child->parent`, set the inverse relationship to the existing parent instance. This avoids an additional lazy-loading query for each child.
```php
$feature->load('comments.user');
$feature->comments->each->setRelation('feature', $feature);
```
## Prefer `whereIn` + Subquery Over `whereHas`
## Compare `whereHas()` with an `IN` Subquery
`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory.
`whereHas()` typically produces an `EXISTS` subquery, while `whereIn()` can express the same filter with an `IN` subquery. Either form may be faster depending on the database engine, indexes, cardinality, and query plan. Measure both forms with representative data; neither subquery loads its result set into PHP memory.
Incorrect (correlated EXISTS re-executes per row):
Option using `EXISTS`:
```php
$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
```
Correct (index-friendly subquery, no PHP memory overhead):
Option using `IN`:
```php
$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
```
## Sometimes Two Simple Queries Beat One Complex Query
## Measure Two Simple Queries Against One Complex Query
Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index.
Two targeted queries can outperform one complex correlated subquery or join when the first query is highly selective. They also add a database round trip, can transfer a large identifier list, and do not provide a single-query consistency snapshot. Decide from query plans and production-like measurements.
## Use Compound Indexes Matching `orderBy` Column Order
## Design Composite Indexes for the Query
When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index.
For common multi-column sorts, consider a composite index whose column order supports the query's filters and ordering. Database engines may combine indexes or choose an explicit sort, so matching the `ORDER BY` list alone does not guarantee that an index will be used. Verify the query plan.
```php
// Migration
$table->index(['last_name', 'first_name']);
// Query — column order must match the index
// Query that this index may support
User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
```
## Use Correlated Subqueries for Has-Many Ordering
## Consider a Correlated Subquery for Has-Many Ordering
When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading.
When sorting by one value from a has-many relationship, a direct join can duplicate parent rows unless it first reduces the related table to one row per parent. A correlated subquery in `orderBy()` is often simpler, but its performance depends on the query plan and supporting indexes.
```php
public function scopeOrderByLastLogin($query): void
@@ -1,8 +1,8 @@
# Architecture Best Practices
## Single-Purpose Action Classes
## Extract Focused Business Operations
Extract discrete business operations into invokable Action classes.
Extract a discrete business operation into an action class when doing so makes the operation easier to reuse or test. An action class has no special meaning to Laravel; follow the project's naming and invocation conventions.
```php
class CreateOrderAction
@@ -19,11 +19,12 @@ class CreateOrderAction
}
```
## Use Dependency Injection
## Inject Required Dependencies
Always use constructor injection. Avoid `app()` or `resolve()` inside classes.
Prefer constructor injection for dependencies required throughout an object's lifetime. Method injection is appropriate for dependencies needed by one controller action, listener, job handler, or other container-invoked method. Avoid `app()` and `resolve()` when normal injection can make a dependency explicit.
Hidden dependency:
Incorrect:
```php
class OrderController extends Controller
{
@@ -36,24 +37,24 @@ class OrderController extends Controller
}
```
Correct:
Injected dependency:
```php
class OrderController extends Controller
{
public function __construct(private OrderService $service) {}
public function store(StoreOrderRequest $request)
public function store(StoreOrderRequest $request, OrderService $service)
{
return $this->service->create($request->validated());
return $service->create($request->validated());
}
}
```
## Code to Interfaces
## Depend on Contracts at Boundaries
Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
Depend on contracts at system boundaries, such as payment gateways, notification channels, and external services, when testability or interchangeable implementations justify the abstraction.
Concrete boundary dependency:
Incorrect (concrete dependency):
```php
class OrderService
{
@@ -61,7 +62,8 @@ class OrderService
}
```
Correct (interface dependency):
Contract boundary dependency:
```php
interface PaymentGateway
{
@@ -80,86 +82,99 @@ Bind in a service provider:
$this->app->bind(PaymentGateway::class, StripeGateway::class);
```
## Default Sort by Descending
## Specify a Deterministic Sort Order
When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
Without an explicit `ORDER BY`, row order is undefined. Choose an order that matches the feature, and add a unique tie-breaker when stable pagination matters.
Unspecified order:
Incorrect:
```php
$posts = Post::paginate();
```
Correct:
Newest first with a stable tie-breaker:
```php
$posts = Post::latest()->paginate();
$posts = Post::query()
->orderByDesc('created_at')
->orderByDesc('id')
->paginate();
```
## Use Atomic Locks for Race Conditions
Prevent race conditions with `Cache::lock()` or `lockForUpdate()`.
Use a lock when concurrent execution must be serialized. `Cache::lock()` provides an atomic lock when the configured cache store supports locks. `lockForUpdate()` locks selected database rows and must run inside a database transaction. These mechanisms solve different coordination problems.
```php
Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
$order->process();
});
// Or at query level
$product = Product::where('id', $id)->lockForUpdate()->first();
// Or at query level, inside a transaction
DB::transaction(function () use ($id) {
$product = Product::where('id', $id)->lockForUpdate()->first();
// Read and update the product while the database lock is held.
});
```
## Use `mb_*` String Functions
When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters.
When no Laravel helper exists, prefer multibyte-aware functions such as `mb_strlen()` and `mb_strtolower()` for UTF-8 text. For example, `strlen()` counts bytes, while `strtolower()` is not multibyte-aware.
Incorrect:
```php
strlen('José'); // 5 (bytes, not characters)
strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte
strlen('José'); // 5 bytes, not 4 characters
strtolower('MÜNCHEN'); // Does not lowercase Ü
```
Correct:
```php
mb_strlen('José'); // 4 (characters)
mb_strtolower('MÜNCHEN'); // 'münchen'
mb_strlen('José'); // 4 characters
mb_strtolower('MÜNCHEN'); // 'münchen'
// Prefer Laravel's Str helpers when available
Str::length('José'); // 4
Str::lower('MÜNCHEN'); // 'münchen'
Str::length('José'); // 4
Str::lower('MÜNCHEN'); // 'münchen'
```
## Use `defer()` for Post-Response Work
For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead.
For lightweight work that does not need retries or crash durability, consider `defer()` instead of dispatching a job. During an HTTP request, the callback normally runs after the response has been sent but remains in the same PHP process.
Queued and durable:
Incorrect (job overhead for trivial work):
```php
dispatch(new LogPageView($page));
```
Correct (runs after response, same process):
Deferred in the current process:
```php
defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()]));
```
Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work.
Use a queued job when the work needs retries, queue controls, or durability across process failures.
## Use `Context` for Request-Scoped Data
The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually.
The `Context` facade makes contextual data available across the current execution lifecycle without manually passing arguments through every layer.
```php
// In middleware
Context::add('tenant_id', $request->header('X-Tenant-ID'));
// Anywhere later — controllers, jobs, log context
// Later in the same execution lifecycle
$tenantId = Context::get('tenant_id');
```
Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`.
Visible context is added to log context, and both visible and hidden context are captured and restored for queued jobs. Use `Context::addHidden()` for data that should propagate to queued jobs without appearing in logs. Do not place secrets in context unless that propagation is intended.
## Use `Concurrency::run()` for Parallel Execution
Run independent operations in parallel using child processes — no async libraries needed.
Run independent operations concurrently through Laravel's configured concurrency driver.
```php
use Illuminate\Support\Facades\Concurrency;
@@ -170,13 +185,14 @@ use Illuminate\Support\Facades\Concurrency;
]);
```
Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially.
With a process-based driver, each closure runs in a separate PHP process that boots the application. Use concurrency when independent database queries, HTTP client calls, or computations benefit enough to offset process and serialization overhead. The `sync` driver executes closures sequentially and is useful primarily during testing.
## Convention Over Configuration
## Follow Framework Conventions
Follow Laravel conventions. Don't override defaults unnecessarily.
Follow Laravel conventions unless the domain or an existing schema requires an override.
Customized schema:
Incorrect:
```php
class Customer extends Model
{
@@ -190,7 +206,8 @@ class Customer extends Model
}
```
Correct:
Conventional schema:
```php
class Customer extends Model
{
@@ -1,8 +1,8 @@
# Blade & Views Best Practices
# Blade and View Best Practices
## Use `$attributes->merge()` in Component Templates
Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly.
Use the component attribute bag so callers can add attributes. `merge()` combines default attributes with caller-provided values; class values receive special merging behavior.
```blade
<div {{ $attributes->merge(['class' => 'alert alert-'.$type]) }}>
@@ -12,25 +12,25 @@ Hardcoding classes prevents consumers from adding their own. `merge()` combines
## Use `@pushOnce` for Per-Component Scripts
If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once.
If a component renders repeatedly, `@push` adds its script on every render. Use a consistently named `@pushOnce` block to add that content once per rendered response.
## Prefer Blade Components Over `@include`
## Prefer Components for Explicit Interfaces
`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots.
Use a Blade component when a reusable interface benefits from explicit props, an attribute bag, or slots. An include remains suitable for a small partial that intentionally uses the current view data; pass an explicit data array when implicit variable sharing would obscure its dependencies.
## Use View Composers for Shared View Data
## Share Compatible View Data with a View Composer
If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it.
Use a view composer to centralize data needed whenever one or more named Blade views are rendered. Keep the composer compatible with every view it targets, and avoid broad wildcards when views require different data shapes. A view composer runs when Laravel renders the matching view; it does not supply data to JSON, streamed, or other non-view responses.
## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
## Return Blade Fragments for Partial Rendering
A single view can return either the full page or just a fragment, keeping routing clean.
A route can return either a full view or a named fragment for clients such as htmx or Turbo.
```php
return view('dashboard', compact('users'))
->fragmentIf($request->hasHeader('HX-Request'), 'user-list');
```
## Use `@aware` for Deeply Nested Component Props
## Share Parent Component Props with `@aware`
Avoids re-passing parent props through every level of nested components.
Use `@aware` when a nested component needs a prop explicitly passed to an ancestor component. It does not expose an ancestor's default prop value unless that value was passed through the attribute bag.
@@ -1,10 +1,13 @@
# Caching Best Practices
## Use `Cache::remember()` Instead of Manual Get/Put
## Use `Cache::remember()` for Cache-Aside Reads
Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
`Cache::remember()` implements a cache-aside read without a separate truthiness check. It does not prevent concurrent requests from computing the same missing value; use an atomic lock when duplicate computation must be prevented.
The manual version below incorrectly treats valid falsy values, such as `false` or `0`, as cache misses.
Incorrect:
```php
$val = Cache::get('stats');
if (! $val) {
@@ -14,27 +17,42 @@ if (! $val) {
```
Correct:
```php
$val = Cache::remember('stats', 60, fn () => $this->computeStats());
```
## Use `Cache::flexible()` for Stale-While-Revalidate
## Consider `Cache::flexible()` for Stale-While-Revalidate
On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background.
For frequently read keys, `Cache::flexible()` can serve stale data during a defined stale period and register a deferred refresh. During an HTTP request, that refresh normally runs after the response; it is not a durable background job. Once the stale period has elapsed, the request recomputes the value synchronously.
Incorrect: `Cache::remember('users', 300, fn () => User::all());`
Synchronous expiration:
Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function.
```php
Cache::remember('users', 300, fn () => User::all());
```
## Use `Cache::memo()` to Avoid Redundant Hits Within a Request
Stale-while-revalidate tradeoff:
If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory.
```php
Cache::flexible('users', [300, 600], fn () => User::all());
```
`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5.
This value is fresh for five minutes and may be served stale until ten minutes after it was cached.
## Use `Cache::memo()` to Avoid Redundant Hits Within an Execution
If the same cache key is read repeatedly during one request or job, `memo()` decorates a cache store and retains resolved values in memory for that execution.
```php
$settings = Cache::memo()->get('settings');
```
Repeated reads through the same memoized store avoid additional store lookups. Writes through the memoized store update or invalidate its in-memory values as appropriate.
## Use Cache Tags to Invalidate Related Groups
Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`.
Tags group related entries for invalidation without tracking each key. Cache tags are not supported by the `file`, `dynamodb`, or `database` drivers; confirm support before choosing a store.
```php
Cache::tags(['user-1'])->flush();
@@ -42,15 +60,27 @@ Cache::tags(['user-1'])->flush();
## Use `Cache::add()` for Atomic Conditional Writes
`add()` only writes if the key does not exist — atomic, no race condition between checking and writing.
`add()` atomically writes a value only when the key does not already exist.
Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }`
Incorrect:
Correct: `Cache::add('lock', true, 10);`
```php
if (! Cache::has('lock')) {
Cache::put('lock', true, 10);
}
```
## Use `once()` for Per-Request Memoization
Correct:
`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory.
```php
Cache::add('lock', true, 10);
```
Use `Cache::lock()` rather than an ordinary cache key when lock ownership and safe release are required.
## Use `once()` for In-Process Memoization
`once()` memoizes a callback's return value for the current request or job. Calls made from an object instance are scoped to that instance. Unlike `Cache::memo()`, `once()` does not read from an external cache store.
```php
public function roles(): Collection
@@ -59,11 +89,11 @@ public function roles(): Collection
}
```
Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching.
Repeated calls return the memoized result without rerunning the callback. Use `once()` for repeated computation within one execution. Use `Cache::memo()` to memoize access to an underlying store that can also persist values across executions.
## Configure Failover Cache Stores in Production
If Redis goes down, the app falls back to a secondary store automatically.
The failover driver tries each configured store in order when a store operation throws an exception. It does not consult later stores for an ordinary cache miss, and data is not replicated between stores.
```php
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
@@ -2,41 +2,69 @@
## Use Higher-Order Messages for Simple Operations
Incorrect:
Explicit closure:
```php
$users->each(function (User $user) {
$user->markAsVip();
});
```
Correct: `$users->each->markAsVip();`
Concise equivalent:
Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc.
```php
$users->each->markAsVip();
```
## Choose `cursor()` vs. `lazy()` Correctly
Higher-order messages are available for supported collection methods such as `each`, `map`, `filter`, and `sum`. Use an explicit closure when arguments or nontrivial logic would be clearer.
- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk).
- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading.
## Choose Between `cursor()` and `lazy()`
Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored.
`cursor()` executes one query and hydrates models individually, but it cannot eager load relationships. The database driver's result buffering can still consume substantial memory for very large results. Use it for low-memory, attribute-only iteration when one long-running query is acceptable.
Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work.
`lazy()` executes multiple chunked queries and returns a flat `LazyCollection`. It supports eager loading relationships for each chunk and avoids holding one database cursor open for the entire iteration.
With relationships:
```php
User::with('roles')->lazy()->each(function (User $user) {
// The roles for this chunk have been eager loaded.
});
```
Without relationships:
```php
User::cursor()->each(function (User $user) {
// Process model attributes.
});
```
## Use `lazyById()` When Updating Records While Iterating
`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation.
`lazy()` uses offset pagination, so updates to columns that affect the query can shift rows and cause records to be skipped or processed twice. `lazyById()` paginates by a monotonic key and is safer when updating other columns during iteration. Do not change the pagination key itself while iterating.
## Use `toQuery()` for Bulk Operations on Collections
Avoids manual `whereIn` construction.
Use `toQuery()` to build a query from the models in an Eloquent collection instead of manually constructing a `whereIn` clause.
Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);`
Manual query:
Correct: `$users->toQuery()->update([...]);`
```php
User::whereIn('id', $users->modelKeys())->update(['active' => false]);
```
Collection query:
```php
$users->toQuery()->update(['active' => false]);
```
`toQuery()` requires a non-empty Eloquent collection whose models are of the same type. Like other bulk Eloquent updates, it does not dispatch per-model update events, so use it only when those events are not required.
## Use `#[CollectedBy]` for Custom Collection Classes
More declarative than overriding `newCollection()`.
The `#[CollectedBy]` attribute declares the custom collection class without requiring a `newCollection()` override.
```php
#[CollectedBy(UserCollection::class)]
@@ -1,73 +1,85 @@
# Configuration Best Practices
## `env()` Only in Config Files
## Read Environment Variables in Configuration Files
Direct `env()` calls may return `null` when config is cached.
Call `env()` only from configuration files. After configuration is cached, Laravel does not load the application's `.env` file, so application code should read configuration values through `config()`.
Incorrect:
```php
$key = env('API_KEY');
```
Correct:
```php
// config/services.php
'key' => env('API_KEY'),
return [
'key' => env('API_KEY'),
];
// Application code
$key = config('services.key');
```
## Use Encrypted Env or External Secrets
## Protect Production Secrets
Never store production secrets in plain `.env` files in version control.
Do not commit plaintext production secrets. Laravel can encrypt an environment file so its encrypted form can be stored safely, while deployment platforms can supply secrets through their native secret stores.
Incorrect:
```bash
# .env committed to repo or shared in Slack
# A plaintext .env file committed to the repository
STRIPE_SECRET=sk_live_abc123
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI
```
Correct:
Encrypted environment file:
```bash
php artisan env:encrypt --env=production --readable
php artisan env:decrypt --env=production
```
For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime.
For hosted deployments, consider the platform's native secret store, such as AWS Secrets Manager or Vault, and inject secrets at runtime.
## Use `App::environment()` for Environment Checks
Incorrect:
```php
if (env('APP_ENV') === 'production') {
// ...
}
```
Correct:
```php
if (app()->isProduction()) {
// or
// ...
}
if (App::environment('production')) {
// ...
}
```
## Use Constants and Language Files
## Name Repeated Domain Values
Use class constants instead of hardcoded magic strings for model states, types, and statuses.
Use an enum or class constant when a domain value is repeated or represents a constrained set. A one-off string literal does not always need a named constant.
```php
// Incorrect
// Repeated literal
return $this->type === 'normal';
// Correct
// Named domain value
return $this->type === self::TYPE_NORMAL;
```
If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there.
If the application supports localization, put user-facing strings in language files and retrieve them with `__()`. Simple literals are reasonable for applications that intentionally do not support multiple languages.
```php
// Only when lang files already exist in the project
// In a localized application
return back()->with('message', __('app.article_added'));
```
@@ -1,10 +1,11 @@
# Database Performance Best Practices
## Always Eager Load Relationships
## Eager Load Relationships Before Iterating
Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront.
When a relationship will be accessed for many models, eager load it with `with()` to avoid running one initial query plus one relationship query per model, commonly called an N+1 query pattern. Lazy loading is reasonable when the relationship may not be needed or only one model is involved.
Lazy-loaded version:
Incorrect (N+1 — executes 1 + N queries):
```php
$posts = Post::all();
foreach ($posts as $post) {
@@ -12,7 +13,8 @@ foreach ($posts as $post) {
}
```
Correct (2 queries total):
Eager-loaded version:
```php
$posts = Post::with('author')->get();
foreach ($posts as $post) {
@@ -20,7 +22,7 @@ foreach ($posts as $post) {
}
```
Constrain eager loads to select only needed columns (always include the foreign key):
Constrain eager loads when large columns are unnecessary. Include the related model's primary key and every column Eloquent needs to match the relationship. In this example, `users.id` and `posts.user_id` match posts to users, while selecting `posts.id` preserves each related model's primary key:
```php
$users = User::with(['posts' => function ($query) {
@@ -42,31 +44,34 @@ public function boot(): void
}
```
Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded.
By default, accessing an unloaded relationship then throws a `LazyLoadingViolationException`. Applications can customize violation handling with `handleLazyLoadingViolationUsing()`.
## Select Only Needed Columns
Avoid `SELECT *` — especially when tables have large text or JSON columns.
Select only the columns the operation needs when omitting large text, binary, or JSON columns provides a meaningful benefit.
All columns:
Incorrect:
```php
$posts = Post::with('author')->get();
```
Correct:
Selected columns:
```php
$posts = Post::select('id', 'title', 'user_id', 'created_at')
->with(['author:id,name,avatar'])
->get();
```
When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match.
When limiting selected columns, retain every key Eloquent needs for matching. A `belongsTo` relationship needs its foreign key on the parent query and the owner's key on the related query. A `hasMany` relationship needs the parent's local key and the related model's foreign key.
## Chunk Large Datasets
## Process Large Data Sets Incrementally
Never load thousands of records at once. Use chunking for batch processing.
Use chunking or lazy iteration when loading an entire result set would exceed the application's practical memory budget.
Loads the complete result set:
Incorrect:
```php
$users = User::all();
foreach ($users as $user) {
@@ -74,7 +79,8 @@ foreach ($users as $user) {
}
```
Correct:
Processes bounded chunks:
```php
User::where('subscribed', true)->chunk(200, function ($users) {
foreach ($users as $user) {
@@ -83,7 +89,7 @@ User::where('subscribed', true)->chunk(200, function ($users) {
});
```
Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change:
Use `chunkById()` when updates can change which rows match the query. Standard `chunk()` uses offset pagination, whose result positions can shift as rows change:
```php
User::where('active', false)->chunkById(200, function ($users) {
@@ -91,11 +97,14 @@ User::where('active', false)->chunkById(200, function ($users) {
});
```
## Add Database Indexes
For read-only, attribute-only iteration, `cursor()` hydrates models individually from one query, although some database drivers still buffer raw results. Use `lazy()` when relationships must be eager loaded in chunks, and use `lazyById()` or `chunkById()` when updates can affect query membership. See the collection rules for detailed tradeoffs.
Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses.
## Add Indexes for Measured Query Patterns
Design indexes around frequent, performance-sensitive query patterns. A column's presence in `WHERE`, `ORDER BY`, `JOIN`, or `GROUP BY` does not by itself justify an index; selectivity, write cost, existing indexes, and the database query plan all matter.
Schema without an application-specific query index:
Incorrect:
```php
Schema::create('orders', function (Blueprint $table) {
$table->id();
@@ -105,24 +114,26 @@ Schema::create('orders', function (Blueprint $table) {
});
```
Correct:
Schema optimized for `WHERE status = ? ORDER BY created_at`:
```php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->index()->constrained();
$table->string('status')->index();
$table->foreignId('user_id')->constrained();
$table->string('status');
$table->timestamps();
$table->index(['status', 'created_at']);
});
```
Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`).
Confirm composite index column order and effectiveness with production-like data and the database's query-plan tools. Also check whether the database already created an index to support a foreign key before adding another one.
## Use `withCount()` for Counting Relations
## Count Relationships Without Loading Them
Never load entire collections just to count them.
Use `withCount()` when only relationship counts are needed; loading and hydrating every related model wastes memory.
Loads related models:
Incorrect:
```php
$posts = Post::all();
foreach ($posts as $post) {
@@ -130,7 +141,8 @@ foreach ($posts as $post) {
}
```
Correct:
Selects relationship counts:
```php
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
@@ -149,39 +161,24 @@ $posts = Post::withCount([
])->get();
```
## Use `cursor()` for Memory-Efficient Iteration
## Keep Queries Out of Blade Templates
For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator.
Prepare data before rendering a Blade template, such as in a controller, query service, or view composer. This keeps query behavior visible and testable.
Incorrect:
```php
$users = User::where('active', true)->get();
```
Query in the template:
Correct:
```php
foreach (User::where('active', true)->cursor() as $user) {
ProcessUser::dispatch($user->id);
}
```
Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records.
## No Queries in Blade Templates
Never execute queries in Blade templates. Pass data from controllers.
Incorrect:
```blade
@foreach (User::all() as $user)
{{ $user->profile->name }}
@endforeach
```
Correct:
Data prepared before rendering:
```php
// Controller
$users = User::with('profile')->get();
return view('users.index', compact('users'));
```
@@ -1,8 +1,8 @@
# Eloquent Best Practices
## Use Correct Relationship Types
## Define Precise Relationship Types
Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints.
Define the relationship that matches the database association, and declare its concrete return type.
```php
public function comments(): HasMany
@@ -20,7 +20,8 @@ public function author(): BelongsTo
Extract reusable query constraints into local scopes to avoid duplication.
Incorrect:
Duplicated constraints:
```php
$active = User::where('verified', true)->whereNotNull('activated_at')->get();
$articles = Article::whereHas('user', function ($q) {
@@ -28,9 +29,11 @@ $articles = Article::whereHas('user', function ($q) {
})->get();
```
Correct:
Reusable local scope:
```php
public function scopeActive(Builder $query): Builder
#[Scope]
protected function active(Builder $query): Builder
{
return $query->where('verified', true)->whereNotNull('activated_at');
}
@@ -44,7 +47,8 @@ $articles = Article::whereHas('user', fn ($q) => $q->active())->get();
Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy.
Incorrect (global scope for a conditional filter):
Global scope tradeoff:
```php
class PublishedScope implements Scope
{
@@ -53,12 +57,15 @@ class PublishedScope implements Scope
$builder->where('published', true);
}
}
// Now admin panels, reports, and background jobs all silently skip drafts
// Admin panels, reports, and jobs now omit drafts unless the scope is removed.
```
Correct (local scope you opt into):
Explicit local scope:
```php
public function scopePublished(Builder $query): Builder
#[Scope]
protected function published(Builder $query): Builder
{
return $query->where('published', true);
}
@@ -82,16 +89,18 @@ protected function casts(): array
}
```
## Cast Date Columns Properly
## Cast Date and Time Attributes
Always cast date columns. Use Carbon instances in templates instead of formatting strings manually.
Cast a date or timestamp attribute when application code should treat it as a Carbon instance. Eloquent already casts the conventional `created_at` and `updated_at` timestamps.
Manual parsing in the template:
Incorrect:
```blade
{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
{{ Carbon::parse($order->ordered_at)->toDateString() }}
```
Correct:
Model cast:
```php
protected function casts(): array
{
@@ -108,24 +117,27 @@ protected function casts(): array
## Use `whereBelongsTo()` for Relationship Queries
Cleaner than manually specifying foreign keys.
`whereBelongsTo()` expresses the relationship constraint without manually specifying its foreign key.
Foreign key constraint:
Incorrect:
```php
Post::where('user_id', $user->id)->get();
```
Correct:
Relationship-aware constraint:
```php
Post::whereBelongsTo($user)->get();
Post::whereBelongsTo($user, 'author')->get();
```
## Avoid Hardcoded Table Names in Queries
## Keep Application Queries Model-Aware
Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string).
Prefer Eloquent models and relationships for model-backed application queries. They preserve casts, scopes, and model table configuration. The query builder and raw SQL legitimately require table names, so use them when their lower-level behavior is intentional.
Lower-level alternatives:
Incorrect:
```php
DB::table('users')->where('active', true)->get();
@@ -134,15 +146,13 @@ $query->join('companies', 'companies.id', '=', 'users.company_id');
DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
```
Correct — reference the model's table:
```php
DB::table((new User)->getTable())->where('active', true)->get();
Model-aware queries:
// Even better — use Eloquent or the query builder instead of raw SQL
```php
User::where('active', true)->get();
Order::where('status', 'pending')->get();
```
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
When a query builder operation should follow a model's configured table name, use `(new User)->getTable()`. For complex joins or raw SQL, explicit table names may be clearer; keep those references covered by tests when schema changes are possible.
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
In migrations, use explicit table names rather than application models. Migrations are historical snapshots, while models and their scopes can change after a migration is deployed.
@@ -1,15 +1,18 @@
# Error Handling Best Practices
## Exception Reporting and Rendering
## Choose Where to Report and Render Exceptions
There are two valid approaches — choose one and apply it consistently across the project.
Laravel supports exception-specific methods and centralized handler callbacks. Follow the pattern already established by the project.
**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find:
Exception methods keep behavior beside the exception definition:
```php
class InvalidOrderException extends Exception
{
public function report(): void { /* custom reporting */ }
public function report(): void
{
// Send the exception to a custom reporter.
}
public function render(Request $request): Response
{
@@ -18,38 +21,40 @@ class InvalidOrderException extends Exception
}
```
**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture:
Centralized callbacks in `bootstrap/app.php` keep the application's exception policy together:
```php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->report(function (InvalidOrderException $e) { /* ... */ });
$exceptions->report(function (InvalidOrderException $e) {
// Send the exception to a custom reporter.
});
$exceptions->render(function (InvalidOrderException $e, Request $request) {
return response()->view('errors.invalid-order', status: 422);
});
})
```
Check the existing codebase and follow whichever pattern is already established.
An exception's `report()` method suppresses Laravel's default reporting unless it returns `false`. A report callback allows default reporting unless it returns `false` or is chained with `stop()`. Use `ShouldntReport` or `dontReport()` when the handler should not report an exception at all. By contrast, returning `false` from a `render()` method or render callback defers to Laravel's default rendering.
## Use `ShouldntReport` for Exceptions That Should Never Log
## Mark Exceptions the Handler Should Not Report
More discoverable than listing classes in `dontReport()`.
Implementing `ShouldntReport` prevents Laravel's exception handler from reporting that exception type and keeps the policy visible on the class. It does not prevent application code from logging the exception explicitly.
```php
class PodcastProcessingException extends Exception implements ShouldntReport {}
```
## Throttle High-Volume Exceptions
## Throttle High-Volume Exception Reports
A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type.
A failing integration can flood logs or error tracking. Configure `throttle()` with a `Lottery` or `Limit` result to sample or rate-limit matching exception reports. Choose keys deliberately when separate exception classes, tenants, or integrations need independent limits.
## Enable `dontReportDuplicates()`
## Prevent Duplicate Reports of One Exception Instance
Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks.
Enable `dontReportDuplicates()` when the same exception object may pass through multiple `report($exception)` calls. It deduplicates by object identity, not by exception class or message.
## Force JSON Error Rendering for API Routes
## Define JSON Rendering for API Routes
Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes.
Laravel normally uses request content negotiation to decide whether to render an exception as JSON. If the application's API contract requires JSON regardless of the `Accept` header, define that policy explicitly for the relevant routes.
```php
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
@@ -59,7 +64,7 @@ $exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
## Add Context to Exception Classes
Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry.
Attach structured data to an exception through `context()`. Laravel merges that data into the exception's log context when the handler reports it.
```php
class InvalidOrderException extends Exception
@@ -1,24 +1,24 @@
# Events & Notifications Best Practices
# Events and Notifications Best Practices
## Rely on Event Discovery
Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`.
Laravel discovers listeners in the configured listener directories by inspecting type-hinted event arguments on `handle()` or `__invoke()` methods. Register listeners manually only when discovery is disabled, the listener is outside those directories, or explicit registration is clearer.
## Run `event:cache` in Production Deploy
## Cache Event Discovery During Production Deployment
Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`.
Cache discovered listeners during production deployment with `php artisan optimize` or `php artisan event:cache`. Rebuild the cache whenever listener definitions change.
## Use `ShouldDispatchAfterCommit` Inside Transactions
Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet.
When an event is dispatched inside a database transaction, `ShouldDispatchAfterCommit` delays dispatch until all open database transactions commit. If a transaction rolls back, Laravel discards the event. This affects synchronous and queued listeners; it is not limited to queue timing.
```php
class OrderShipped implements ShouldDispatchAfterCommit {}
```
## Always Queue Notifications
## Queue Slow Notifications
Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response.
Queue notifications that call external services, such as email, text messaging, or Slack, when they do not need to complete before the response. Keep a notification synchronous when immediate completion or failure feedback is part of the operation.
```php
class InvoicePaid extends Notification implements ShouldQueue
@@ -27,9 +27,9 @@ class InvoicePaid extends Notification implements ShouldQueue
}
```
## Use `afterCommit()` on Notifications in Transactions
## Dispatch Queued Notifications After Commit
Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
A queued notification sent inside a database transaction can run before the transaction commits. Call `afterCommit()` on the queued notification, or enable the queue connection's `after_commit` option, when its delivery depends on committed data. This setting has no scheduling effect on a synchronous notification.
```php
$user->notify((new InvoicePaid($invoice))->afterCommit());
@@ -37,7 +37,7 @@ $user->notify((new InvoicePaid($invoice))->afterCommit());
## Route Notification Channels to Dedicated Queues
Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues.
Different notification channels can have different latency and priority requirements. Implement `viaQueues()` when channels should use separate queues.
## Use On-Demand Notifications for Non-User Recipients
@@ -49,4 +49,4 @@ Notification::route('mail', 'admin@example.com')->notify(new SystemAlert());
## Implement `HasLocalePreference` on Notifiable Models
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
Implement `HasLocalePreference::preferredLocale()` on a notifiable model when notifications and mailables should use the recipient's locale. Laravel also preserves that locale for queued delivery. An explicit `locale()` call can still override the preference for an individual notification.
@@ -1,86 +1,100 @@
# HTTP Client Best Practices
## Always Set Explicit Timeouts
## Set Explicit Timeouts
The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast.
Laravel's HTTP client has a 30-second response timeout by default. Choose response and connection timeouts that fit the service and the calling request or job. Remember that retries can multiply the total elapsed time.
Less resilient:
Incorrect:
```php
$response = Http::get('https://api.example.com/users');
```
Correct:
Preferred:
```php
$response = Http::timeout(5)
->connectTimeout(3)
$response = Http::connectTimeout(3)
->timeout(5)
->get('https://api.example.com/users');
```
For service-specific clients, define timeouts in a macro:
Define shared settings in a macro or a dedicated client:
```php
Http::macro('github', function () {
return Http::baseUrl('https://api.github.com')
->timeout(10)
->connectTimeout(3)
->timeout(10)
->withToken(config('services.github.token'));
});
$response = Http::github()->get('/repos/laravel/framework');
```
## Use Retry with Backoff for External APIs
## Retry Only Safe Operations
External APIs have transient failures. Use `retry()` with increasing delays.
Retry transient connection failures, rate-limit responses, and server errors with an appropriate delay. Retry idempotent requests such as `GET` when the operation can safely run more than once. Retry a state-changing request only when the remote API supports an idempotency key or provides equivalent duplicate protection.
Incorrect:
```php
$response = Http::post('https://api.stripe.com/v1/charges', $data);
Unsafe without an idempotency guarantee:
if ($response->failed()) {
throw new PaymentFailedException('Charge failed');
}
```
Correct:
```php
$response = Http::retry([100, 500, 1000])
->timeout(10)
->post('https://api.stripe.com/v1/charges', $data);
->post('https://api.example.com/v1/charges', $data);
```
Only retry on specific errors:
Safe for an idempotent request:
```php
$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
return $exception instanceof ConnectionException
|| ($exception instanceof RequestException && $exception->response->serverError());
})->post('https://api.example.com/data');
$response = Http::connectTimeout(3)
->timeout(10)
->retry([100, 500, 1000], 0, function (Throwable $exception) {
return $exception instanceof ConnectionException
|| ($exception instanceof RequestException
&& ($exception->response->serverError() || $exception->response->status() === 429));
})
->get('https://api.example.com/data');
```
For a supported state-changing API, send a stable idempotency key for every attempt:
```php
$response = Http::withHeaders(['Idempotency-Key' => $paymentAttempt->uuid])
->connectTimeout(3)
->timeout(10)
->retry([100, 500, 1000], 0, function (Throwable $exception) {
return $exception instanceof ConnectionException
|| ($exception instanceof RequestException
&& ($exception->response->serverError() || $exception->response->status() === 429));
})
->post('https://api.example.com/v1/charges', $data);
```
## Handle Errors Explicitly
The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`.
The HTTP client returns responses for `4xx` and `5xx` status codes instead of throwing by default. Inspect the expected statuses or call `throw()` before consuming a success payload.
Unsafe when a success payload is expected:
Incorrect:
```php
$response = Http::get('https://api.example.com/users/1');
$user = $response->json(); // Could be an error body
$user = Http::get('https://api.example.com/users/1')->json();
```
Correct:
Preferred:
```php
$response = Http::timeout(5)
$user = Http::connectTimeout(3)
->timeout(5)
->get('https://api.example.com/users/1')
->throw();
$user = $response->json();
->throw()
->json();
```
For graceful degradation:
Handle expected alternatives explicitly when graceful degradation is required:
```php
$response = Http::get('https://api.example.com/users/1');
$response = Http::connectTimeout(3)
->timeout(5)
->get('https://api.example.com/users/1');
if ($response->successful()) {
return $response->json();
@@ -93,46 +107,30 @@ if ($response->notFound()) {
$response->throw();
```
## Use Request Pooling for Concurrent Requests
## Pool Independent Requests
When making multiple independent API calls, use `Http::pool()` instead of sequential calls.
Use `Http::pool()` when several independent requests can run concurrently. Pooling changes execution time, not error handling; inspect or throw for each response as needed.
Incorrect:
```php
$users = Http::get('https://api.example.com/users')->json();
$posts = Http::get('https://api.example.com/posts')->json();
$comments = Http::get('https://api.example.com/comments')->json();
```
Correct:
```php
use Illuminate\Http\Client\Pool;
$responses = Http::pool(fn (Pool $pool) => [
$pool->as('users')->get('https://api.example.com/users'),
$pool->as('posts')->get('https://api.example.com/posts'),
$pool->as('comments')->get('https://api.example.com/comments'),
$pool->as('users')->connectTimeout(3)->timeout(5)
->get('https://api.example.com/users'),
$pool->as('posts')->connectTimeout(3)->timeout(5)
->get('https://api.example.com/posts'),
]);
$users = $responses['users']->json();
$posts = $responses['posts']->json();
$users = $responses['users']->throw()->json();
$posts = $responses['posts']->throw()->json();
```
## Fake HTTP Calls in Tests
## Fake HTTP Requests in Tests
Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`.
Use `Http::fake()` for external integrations, and use `Http::preventStrayRequests()` when an unexpected real request should fail the test. Also test timeouts, connection failures, and error responses that the application handles.
Incorrect:
```php
it('syncs user from API', function () {
$service = new UserSyncService;
$service->sync(1); // Hits the real API
});
```
Correct:
```php
it('syncs user from API', function () {
it('syncs a user from the API', function () {
Http::preventStrayRequests();
Http::fake([
@@ -142,16 +140,15 @@ it('syncs user from API', function () {
]),
]);
$service = new UserSyncService;
$service->sync(1);
(new UserSyncService)->sync(1);
Http::assertSent(function (Request $request) {
return $request->url() === 'https://api.example.com/users/1';
});
Http::assertSent(fn (Request $request) =>
$request->url() === 'https://api.example.com/users/1'
);
});
```
Test failure scenarios too:
For example, fake a connection failure when testing the integration's failure path:
```php
Http::fake([
@@ -1,27 +1,54 @@
# Mail Best Practices
## Implement `ShouldQueue` on the Mailable Class
## Queue Slow Mail Delivery
Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it.
Implement `ShouldQueue` on a mailable when delivery should normally happen in the background. Laravel queues that mailable even when the call site uses `Mail::send()`.
## Use `afterCommit()` on Mailables Inside Transactions
```php
class OrderShipped extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
}
```
A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor.
Keep mail synchronous when the caller must know immediately whether delivery was accepted, or when no queue worker is available.
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
## Dispatch Queued Mail After Commit
`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
A queued mailable dispatched during a database transaction can be processed before the transaction commits. Call `afterCommit()` on the mailable, or enable the queue connection's `after_commit` option, when the mail depends on committed records.
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
```php
Mail::to($user)->send(
(new OrderShipped($order))->afterCommit()
);
```
Correct: `Mail::assertQueued(OrderShipped::class);`
If the transaction rolls back, an after-commit mailable is not dispatched. This setting affects queued mail only; it does not defer synchronous delivery.
## Use Markdown Mailables for Transactional Emails
## Assert the Delivery Mode
Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag.
Use `Mail::assertQueued()` for queued mailables and `Mail::assertSent()` for synchronously sent mailables.
## Separate Content Tests from Sending Tests
Incorrect for a mailable that implements `ShouldQueue`:
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
Don't mix them — it conflates concerns and makes tests brittle.
```php
Mail::assertSent(OrderShipped::class);
```
Correct:
```php
Mail::assertQueued(OrderShipped::class);
```
## Use Markdown Mailables When They Fit
Markdown mailables render HTML and plain-text versions from Laravel's mail components and support publishable themes. They are useful for conventional transactional messages, but a custom HTML and text pair may be more appropriate for a specialized design.
```bash
php artisan make:mail OrderShipped --markdown=mail.orders.shipped
```
## Separate Content and Delivery Tests
Test rendered content by instantiating the mailable and using assertions such as `assertSeeInHtml()` and `assertSeeInText()`. Test delivery separately with `Mail::fake()` and `assertSent()` or `assertQueued()` so failures identify the affected behavior.
@@ -2,76 +2,51 @@
## Generate Migrations with Artisan
Always use `php artisan make:migration` for consistent naming and timestamps.
Use `php artisan make:migration` to generate the timestamped filename and migration structure.
Incorrect (manually created file):
```php
// database/migrations/posts_migration.php ← wrong naming, no timestamp
```
Correct (Artisan-generated):
```bash
php artisan make:migration create_posts_table
php artisan make:migration add_slug_to_posts_table
```
## Use `constrained()` for Foreign Keys
## Define Foreign-Key Constraints Deliberately
Automatic naming and referential integrity.
Use `constrained()` when its naming conventions and default actions match the relationship. Specify the table or delete behavior when they do not.
```php
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
// Non-standard names
$table->foreignId('author_id')->constrained('users');
```
## Never Modify Deployed Migrations
Do not add a duplicate single-column index without checking the database driver's treatment of foreign-key indexes and the indexes already created by the migration.
Once a migration has run in production, treat it as immutable. Create a new migration to change the table.
## Treat Deployed Migrations as Immutable
After a migration has run in a shared or production environment, create a new migration for subsequent changes. Editing the old file makes fresh installations differ from upgraded installations.
For a local migration that has not been shared or deployed, editing and rerunning it may be simpler.
## Design Indexes for Real Queries
Add indexes based on query patterns, selectivity, write cost, and the database's ability to use composite indexes. A column appearing in `WHERE`, `ORDER BY`, or `JOIN` does not automatically need its own index.
Declare each selected index in the schema migration that creates or changes the relevant table. Confirm important indexes with representative data and the database's query plan, and avoid redundant indexes whose leading columns duplicate an existing index without serving a distinct query. See the database performance and advanced query rules for index selection and column-order guidance.
## Stage Changes That Affect Existing Rows
Adding a required or unique column to a populated table often needs multiple deployment-safe steps. Add a nullable column, deploy code that can handle both states, backfill existing rows in bounded chunks, then add the required constraint or index after the data is valid.
Do not assume this migration is safe on a populated table:
Incorrect (editing a deployed migration):
```php
// 2024_01_01_create_posts_table.php — already in production
$table->string('slug')->unique(); // ← added after deployment
$table->string('slug')->unique();
```
Correct (new migration to alter):
```php
// 2024_03_15_add_slug_to_posts_table.php
Schema::table('posts', function (Blueprint $table) {
$table->string('slug')->unique()->after('title');
});
```
Large backfills are usually better implemented as an observable, restartable command or job than inside a schema migration. Small deterministic data changes may be reasonable in a migration when their locking, transaction, and deployment behavior is understood.
## Add Indexes in the Migration
## Mirror Defaults Only When Unsaved Models Need Them
Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes.
Incorrect:
```php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('status');
$table->timestamps();
});
```
Correct:
```php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->index();
$table->string('status')->index();
$table->timestamp('shipped_at')->nullable()->index();
$table->timestamps();
});
```
## Mirror Defaults in Model `$attributes`
When a column has a database default, mirror it in the model so new instances have correct values before saving.
A database default is applied when a row is inserted, not when a model is instantiated. Mirror the value in the model's `$attributes` only when application code must observe that default before persistence, and keep both definitions synchronized.
```php
// Migration
@@ -83,39 +58,10 @@ protected $attributes = [
];
```
## Write Reversible `down()` Methods by Default
## Make Rollbacks Honest
Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments.
```php
public function down(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->dropColumn('slug');
});
}
```
For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported.
Implement `down()` when the change can be safely reversed. A rollback that drops populated columns or cannot restore transformed data is destructive even if it is syntactically reversible; document that limitation and prefer a forward-fix migration in production.
## Keep Migrations Focused
One concern per migration. Never mix DDL (schema changes) and DML (data manipulation).
Incorrect (partial failure creates unrecoverable state):
```php
public function up(): void
{
Schema::create('settings', function (Blueprint $table) { ... });
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
}
```
Correct (separate migrations):
```php
// Migration 1: create_settings_table
Schema::create('settings', function (Blueprint $table) { ... });
// Migration 2: seed_default_settings
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
```
Keep each migration small enough to reason about, deploy, and reverse. Separate long-running backfills from schema changes when doing so reduces locks and supports phased deployment, but do not split related operations merely to enforce a blanket separation between data definition and data manipulation.
@@ -1,82 +1,82 @@
# Queue & Job Best Practices
# Queue and Job Best Practices
## Set `retry_after` Greater Than `timeout`
## Keep Reservation Time Longer Than Execution Time
If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution.
For queue drivers that use Laravel's `retry_after` setting, configure it to exceed the longest worker or job timeout by a safety margin. When a reservation expires, another worker can reserve the same job while the first process is still running. Keep the worker's `--timeout` several seconds shorter than `retry_after`.
Incorrect (`retry_after` ≤ `timeout`):
```php
class ProcessReport implements ShouldQueue
{
public $timeout = 120;
}
// Job
public $timeout = 120;
// config/queue.php — retry_after: 90 ← job retried while still running!
// config/queue.php for the connection
'retry_after' => 150,
```
Correct (`retry_after` > `timeout`):
```php
class ProcessReport implements ShouldQueue
{
public $timeout = 120;
}
Amazon Simple Queue Service uses its visibility timeout instead of Laravel's `retry_after`; configure that timeout at the queue level. Because workers can also stop after side effects but before acknowledging a job, make important jobs idempotent even with correct timeout settings.
// config/queue.php — retry_after: 180 ← safely longer than any job timeout
```
## Back Off Transient Failures
## Use Exponential Backoff
Use progressively longer delays when a dependency needs time to recover. Do not retry permanent validation or business-rule failures.
Use progressively longer delays between retries to avoid hammering failing services.
Incorrect (fixed retry interval):
```php
class SyncWithStripe implements ShouldQueue
{
public $tries = 3;
// Default: retries immediately, overwhelming the API
}
```
public $tries = 4;
Correct (exponential backoff):
```php
class SyncWithStripe implements ShouldQueue
{
public $tries = 3;
public $backoff = [1, 5, 10];
}
```
## Implement `ShouldBeUnique`
Rate-limiting and exception-throttling middleware can release jobs back to the queue. Released attempts may still count toward the maximum attempt limit, so configure `$tries` or `retryUntil()` to allow the intended retry window.
Prevent duplicate job processing.
## Use Unique Jobs for Dispatch Deduplication
Implement `ShouldBeUnique` when only one queued instance of a logical job should exist. Uniqueness uses a cache lock and is not a substitute for idempotent processing or a database constraint.
```php
class GenerateInvoice implements ShouldQueue, ShouldBeUnique
{
public $uniqueFor = 3600;
public function uniqueId(): string
{
return $this->order->id;
return (string) $this->order->id;
}
public $uniqueFor = 3600;
}
```
## Always Implement `failed()`
All dispatching processes must use a shared cache that supports locks. Unique-job constraints do not apply to jobs within batches.
Handle errors explicitly — don't rely on silent failure.
Use `ShouldBeUniqueUntilProcessing` only when the lock should be released immediately before processing begins, allowing another instance to be dispatched while the first is running:
```php
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
// ...
}
```
## Handle Terminal Failure When Needed
Implement `failed()` when the application must update state, alert an operator, or record domain-specific context after all attempts are exhausted. Logging every failure in each job may duplicate the queue system's failure reporting.
Laravel invokes `failed()` on a new job instance, so mutations made to the job during `handle()` are not available there.
```php
public function failed(?Throwable $exception): void
{
$this->podcast->update(['status' => 'failed']);
Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]);
Log::error('Podcast processing failed', [
'podcast_id' => $this->podcast->id,
'exception' => $exception,
]);
}
```
## Rate Limit External API Calls in Jobs
## Rate Limit External Calls
Use `RateLimited` middleware to throttle jobs calling third-party APIs.
Use queue middleware such as `RateLimited` when jobs share a third-party API quota. Define the named limiter and choose release delays and attempt limits together.
```php
public function middleware(): array
@@ -85,60 +85,33 @@ public function middleware(): array
}
```
## Batch Related Jobs
## Batch Jobs for Group Coordination
Use `Bus::batch()` when jobs should succeed or fail together.
Use `Bus::batch()` to monitor a group of jobs and run callbacks when the batch completes or encounters failures. A batch is not a database transaction: completed jobs are not rolled back when another job fails. By default, one failed job cancels the batch; call `allowFailures()` only when partial failure is acceptable.
```php
Bus::batch([
new ImportCsvChunk($chunk1),
new ImportCsvChunk($chunk2),
])
->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed'))
->dispatch();
->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
->catch(fn (Batch $batch, Throwable $exception) => Log::error('Import batch failed', [
'exception' => $exception,
]))
->dispatch();
```
## `retryUntil()` Needs `$tries = 0`
## Configure Time-Based Retry Limits Deliberately
When using time-based retry limits, set `$tries = 0` to avoid premature failure.
Use `retryUntil()` as the time-based alternative to a maximum attempt count. Laravel may attempt the job any number of times until this deadline, subject to other failure conditions such as maximum exceptions. The method takes precedence over attempt-based limits, so setting `$tries = 0` is not required.
```php
public $tries = 0;
public function retryUntil(): \DateTimeInterface
public function retryUntil(): DateTimeInterface
{
return now()->addHours(4);
}
```
## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
## Use Horizon for Redis Queue Operations
`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
```php
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
// Lock releases when processing begins, not when it finishes
}
```
## Use Horizon for Complex Queue Scenarios
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
```php
// config/horizon.php
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['high', 'default', 'low'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 10,
'tries' => 3,
],
],
],
```
Laravel Horizon provides monitoring, balancing, metrics, and supervisor configuration for Redis queues. It does not support non-Redis queue drivers.
@@ -1,99 +1,106 @@
# Routing & Controllers Best Practices
# Routing and Controller Best Practices
## Use Implicit Route Model Binding
Let Laravel resolve models automatically from route parameters.
Let Laravel resolve models from route parameters when the default lookup and missing-model behavior fit the endpoint.
Instead of manual lookup:
Incorrect:
```php
public function show(int $id)
public function show(int $id): View
{
$post = Post::findOrFail($id);
return view('posts.show', ['post' => $post]);
}
```
Correct:
Use route model binding:
```php
public function show(Post $post)
public function show(Post $post): View
{
return view('posts.show', ['post' => $post]);
}
```
## Use Scoped Bindings for Nested Resources
## Scope Nested Bindings
Enforce parent-child relationships automatically.
Use scoped bindings when a nested resource must belong to its parent. This constrains model resolution; it does not replace authorization.
```php
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
// $post is automatically scoped to $user
// The resolved post belongs to the resolved user.
})->scopeBindings();
```
## Use Resource Controllers
## Use Resource Routes for Resourceful Actions
Use `Route::resource()` or `apiResource()` for RESTful endpoints.
Use `Route::resource()` or `Route::apiResource()` when the endpoint follows Laravel's resource-controller actions. Define explicit routes when the behavior does not fit that vocabulary.
```php
Route::resource('posts', PostController::class);
// In routes/api.php — the /api prefix is applied automatically
Route::apiResource('posts', Api\PostController::class);
// Alternatively, for an API-only resource:
Route::apiResource('posts', ApiPostController::class);
```
## Keep Controllers Thin
`apiResource()` omits the HTML-oriented `create` and `edit` routes. It does not itself add an `/api` prefix; that prefix comes from the application's API route configuration.
Aim for under 10 lines per method. Extract business logic to action or service classes.
## Organize Controllers Around Resources
As a general default, organize each controller around one resource and use Laravel's standard resource actions: `index`, `show`, `create`, `store`, `edit`, `update`, and `destroy`. This keeps routes predictable and prevents controllers from accumulating unrelated behavior.
When a controller needs a custom action such as `publish`, `approve`, or `archive`, first consider whether that behavior represents a separate resource. A focused resource controller gives the behavior its own authorization, validation, and middleware boundary.
Custom action on the primary controller:
Incorrect:
```php
public function store(Request $request)
Route::post('/podcasts/{podcast}/publish', [PodcastController::class, 'publish']);
```
The published podcast modeled as a resource:
```php
Route::post('/published-podcasts/{podcast}', [PublishedPodcastController::class, 'store'])
->name('published-podcasts.store');
Route::delete('/published-podcasts/{podcast}', [PublishedPodcastController::class, 'destroy'])
->name('published-podcasts.destroy');
```
```php
class PublishedPodcastController extends Controller
{
$validated = $request->validate([...]);
if ($request->hasFile('image')) {
$request->file('image')->move(public_path('images'));
public function store(Podcast $podcast): RedirectResponse
{
$podcast->publish();
return back();
}
public function destroy(Podcast $podcast): RedirectResponse
{
$podcast->unpublish();
return back();
}
$post = Post::create($validated);
$post->tags()->sync($validated['tags']);
event(new PostCreated($post));
return redirect()->route('posts.show', $post);
}
```
Correct:
Treat a custom verb as a design signal, not proof that another controller is required. Use query parameters for simple filtering, and keep an explicit action route when modeling the operation as a resource would obscure the domain or conflict with established project conventions.
## Keep Controllers Focused on HTTP Concerns
Controllers should coordinate HTTP input, authorization, validation, an application operation, and the response. Extract substantial or reusable business logic, but do not introduce an action or service merely to satisfy an arbitrary line limit.
```php
public function store(StorePostRequest $request, CreatePostAction $create)
public function store(StorePostRequest $request, CreatePostAction $create): RedirectResponse
{
$post = $create->execute($request->validated());
$post = $create->handle($request->validated());
return redirect()->route('posts.show', $post);
}
```
## Type-Hint Form Requests
Type-hinting Form Requests triggers automatic validation and authorization before the method executes.
Incorrect:
```php
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => ['required', 'max:255'],
'body' => ['required'],
]);
Post::create($validated);
return redirect()->route('posts.index');
}
```
Correct:
```php
public function store(StorePostRequest $request): RedirectResponse
{
Post::create($request->validated());
return redirect()->route('posts.index');
}
```
A form request can perform validation and authorization before the controller runs. Do not repeat its rules in the controller. Keep simple, endpoint-specific validation inline when extraction would not improve reuse or clarity; see the validation rules for detailed guidance.
@@ -1,32 +1,50 @@
# Task Scheduling Best Practices
## Use `withoutOverlapping()` on Variable-Duration Tasks
## Prevent Unwanted Overlap
Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion.
## Use `onOneServer()` on Multi-Server Deployments
Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached).
## Use `runInBackground()` for Concurrent Long Tasks
By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes.
## Use `environments()` to Restrict Tasks
Prevent accidental execution of production-only tasks (billing, reporting) on staging.
Use `withoutOverlapping()` when a second run must not begin while the previous run holds the lock. This is appropriate for variable-duration tasks that are not safe to run concurrently.
```php
Schedule::command('billing:charge')->monthly()->environments(['production']);
Schedule::command('reports:generate')
->everyFifteenMinutes()
->withoutOverlapping(30);
```
## Use `takeUntilTimeout()` for Time-Bounded Processing
The optional value is the lock expiration time in minutes, not the task timeout. Choose it carefully: the default is 24 hours, stale locks can be cleared with `php artisan schedule:clear-cache`, and an expiration that is too short can permit overlap while the first task still runs. The task itself should still tolerate retries and partial execution where practical.
A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time.
## Run a Task on One Server
## Use Schedule Groups for Shared Configuration
Use `onOneServer()` when only one scheduler node should run an eligible task. Scheduler nodes must use the same default cache store, and that store must support atomic locks. Supported stores include `database`, `memcached`, `dynamodb`, and `redis`.
Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks.
```php
Schedule::command('billing:charge')->daily()->onOneServer();
```
Name scheduled closures before applying `onOneServer()`, especially when scheduling the same closure with different parameters, so each task has a distinct lock identity.
## Run Eligible Commands in the Background
Tasks due at the same time run sequentially by default. Use `runInBackground()` when an independent, long-running scheduled command should not delay later tasks.
```php
Schedule::command('analytics:process')->hourly()->runInBackground();
```
Laravel restricts `runInBackground()` to tasks scheduled with `command()` and `exec()`; it is not available for scheduled closures. Ensure background processes have appropriate logging and failure monitoring.
## Restrict Tasks by Environment
Use `environments()` when a task should run only in named application environments. Treat this as an operational safeguard, not an authorization control.
```php
Schedule::command('billing:charge')
->monthly()
->environments(['production']);
```
## Group Shared Configuration
Use schedule groups when several tasks genuinely share frequency or constraints.
```php
Schedule::daily()
@@ -37,3 +55,7 @@ Schedule::daily()
Schedule::command('emails:prune');
});
```
## Bound Work Inside the Task
The scheduler does not provide a `takeUntilTimeout()` event method or terminate arbitrary tasks at a deadline. Bound work in the command or job itself by processing finite chunks, checking a deadline, or dispatching queue jobs with suitable timeouts. Use operating-system or process controls when hard termination is required.
@@ -1,18 +1,9 @@
# Security Best Practices
## Mass Assignment Protection
## Control Mass Assignment
Every model must define `$fillable` (whitelist) or `$guarded` (blacklist).
Define `$fillable` when a model is populated from request-derived arrays, or deliberately guard attributes by another consistent model convention. Laravel models guard all attributes by default; `$guarded = []` opts out of that protection.
Incorrect:
```php
class User extends Model
{
protected $guarded = []; // All fields are mass assignable
}
```
Correct:
```php
class User extends Model
{
@@ -24,82 +15,71 @@ class User extends Model
}
```
Never use `$guarded = []` on models that accept user input.
Do not pass untrusted request data to a model with `$guarded = []`. Mass-assignment protection controls which attributes `create()`, `fill()`, and `update()` may set; it does not validate values or authorize the operation.
## Authorize Every Action
## Authorize Protected Actions
Use policies or gates in controllers. Never skip authorization.
Use policies, gates, or form request authorization for actions that depend on the current user's permissions. Authentication alone does not establish permission, and validation is not authorization.
Incorrect:
```php
public function update(UpdatePostRequest $request, Post $post)
{
$post->update($request->validated());
}
```
Correct:
```php
public function update(UpdatePostRequest $request, Post $post)
public function update(UpdatePostRequest $request, Post $post): RedirectResponse
{
Gate::authorize('update', $post);
$post->update($request->validated());
return redirect()->route('posts.show', $post);
}
```
Or via Form Request:
Authorization may instead live in the form request:
```php
public function authorize(): bool
{
return $this->user()->can('update', $this->route('post'));
return $this->user()?->can('update', $this->route('post')) ?? false;
}
```
## Prevent SQL Injection
Public actions intentionally available to everyone do not need a redundant authorization check.
Always use parameter binding. Never interpolate user input into queries.
## Bind Query Parameters
Use Eloquent, the query builder, or explicit bindings instead of interpolating untrusted values into Structured Query Language (SQL). Bindings protect values, not identifiers such as column names or sort directions; map user-selected identifiers to an allow-list.
Incorrect:
```php
DB::select("SELECT * FROM users WHERE name = '{$request->name}'");
```
Correct:
```php
User::where('name', $request->name)->get();
// Raw expressions with bindings
User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get();
User::whereRaw('LOWER(name) = ?', [$request->string('name')->lower()->toString()])->get();
```
## Escape Output to Prevent XSS
## Escape Output in Its Context
Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content.
Blade's `{{ }}` syntax HTML-escapes output. Use `{!! !!}` only for content that has been sanitized for the exact HTML context in which it is rendered. Escaping rules differ for HTML, URLs, JavaScript, and Cascading Style Sheets.
Incorrect for untrusted content:
Incorrect:
```blade
{!! $user->bio !!}
```
Correct:
```blade
{{ $user->bio }}
```
## CSRF Protection
## Apply Cross-Site Request Forgery Protection
Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
Include `@csrf` in state-changing Blade forms handled by Laravel's `web` middleware. Routes intentionally excluded from cross-site request forgery (CSRF) verification, such as validated third-party webhooks, need their own authenticity check.
Incorrect:
```blade
<form method="POST" action="/posts">
<input type="text" name="title">
</form>
```
Correct:
```blade
<form method="POST" action="/posts">
@csrf
@@ -107,21 +87,27 @@ Correct:
</form>
```
## Rate Limit Auth and API Routes
Inertia applications commonly use Axios, which returns the encrypted `XSRF-TOKEN` cookie in the `X-XSRF-TOKEN` header. Confirm equivalent configuration when using another HTTP client. Do not disable CSRF protection merely to fix a token mismatch.
Apply `throttle` middleware to authentication and API routes.
## Rate Limit Sensitive Endpoints
Apply suitable rate limits to login attempts, password recovery, verification messages, and expensive or abuse-prone application programming interface (API) routes. Choose the limiter key deliberately; an Internet Protocol (IP) address alone can unfairly group users behind a shared network, while an account identifier alone can enable targeted denial of service.
```php
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
return Limit::perMinute(5)->by(Str::transliterate(
Str::lower($request->string('email')).'|'.$request->ip()
));
});
Route::post('/login', LoginController::class)->middleware('throttle:login');
```
## Validate File Uploads
Rate limiting reduces abuse; it does not replace authentication, authorization, or upstream denial-of-service protection.
Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
## Validate and Store Uploads Safely
Validate expected content type, dimensions where relevant, and size. Laravel's `mimes` rule reads the file contents and guesses a Multipurpose Internet Mail Extensions (MIME) type corresponding to the listed extensions; it does not validate the user-assigned filename extension. The `extensions` rule checks that extension and should not be used by itself.
```php
public function rules(): array
@@ -132,56 +118,28 @@ public function rules(): array
}
```
Store with generated filenames:
Use Laravel's storage methods to generate a filename, and store untrusted files outside a publicly executable location. Public files can require additional controls, such as image re-encoding, content-disposition headers, and explicit blocking of active formats.
```php
$path = $request->file('avatar')->store('avatars', 'public');
$path = $request->file('avatar')->store('avatars');
```
## Keep Secrets Out of Code
## Keep Secrets Out of Application Code
Never commit `.env`. Access secrets via `config()` only.
Incorrect:
```php
$key = env('API_KEY');
```
Correct:
```php
// config/services.php
'api_key' => env('API_KEY'),
// In application code
$key = config('services.api_key');
```
Do not commit populated environment files or hard-code credentials. Read environment variables in configuration files, then use `config()` in application code so configuration caching works correctly. See the configuration rules for encrypted environment files and external secret stores.
## Audit Dependencies
Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment.
Run `composer audit` regularly and in continuous integration. Review findings for exploitability and update or mitigate affected packages promptly.
```bash
composer audit
```
## Encrypt Sensitive Database Fields
## Encrypt Sensitive Attributes When Appropriate
Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`.
Use an `encrypted` cast for sensitive values that must be recoverable, and use `$hidden` to omit them from array and JavaScript Object Notation (JSON) serialization. Hidden attributes remain accessible in PHP, and encryption does not replace access control. Encrypted values cannot be meaningfully queried and should use a `TEXT` or larger column because ciphertext length is variable.
Incorrect:
```php
class Integration extends Model
{
protected function casts(): array
{
return [
'api_key' => 'string',
];
}
}
```
Correct:
```php
class Integration extends Model
{
@@ -1,125 +1,110 @@
# Conventions & Style
# Convention and Style Best Practices
## Follow Laravel Naming Conventions
## Follow Project Naming Conventions
| What | Convention | Good | Bad |
|------|-----------|------|-----|
| Controller | singular | `ArticleController` | `ArticlesController` |
| Model | singular | `User` | `Users` |
| Table | plural, snake_case | `article_comments` | `articleComments` |
| Pivot table | singular alphabetical | `article_user` | `user_article` |
| Column | snake_case, no model name | `meta_title` | `article_meta_title` |
| Foreign key | singular model + `_id` | `article_id` | `articles_id` |
| Route | plural | `articles/1` | `article/1` |
| Route name | snake_case with dots | `users.show_active` | `users.show-active` |
| Method | camelCase | `getAll` | `get_all` |
| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` |
| Collection | descriptive, plural | `$activeUsers` | `$data` |
| Object | descriptive, singular | `$activeUser` | `$users` |
| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` |
| Config | snake_case | `google_calendar.php` | `googleCalendar.php` |
| Enum | singular | `UserType` | `UserTypes` |
Prefer Laravel's conventions in new code, but preserve an established project convention unless a coordinated rename is worthwhile.
## Prefer Shorter Readable Syntax
| Element | Convention | Example |
| --- | --- | --- |
| Controller | Singular resource name | `ArticleController` |
| Model | Singular StudlyCase | `User` |
| Table | Plural snake_case | `article_comments` |
| Pivot table | Singular model names in alphabetical order, in snake_case | `article_user` |
| Column | snake_case | `meta_title` |
| Conventional foreign key | Singular model name plus `_id`, in snake_case | `article_id` |
| Resource URI | Plural resource | `articles/1` |
| Route name | Dotted segments; snake_case within a segment when needed | `users.show_active` |
| Method | camelCase | `getAll` |
| Variable | camelCase | `$articlesWithAuthor` |
| Collection | Descriptive and plural | `$activeUsers` |
| Object | Descriptive and singular | `$activeUser` |
| View | kebab-case | `show-filtered.blade.php` |
| Configuration file | snake_case | `google_calendar.php` |
| Enumeration | Singular StudlyCase | `UserType` |
| Verbose | Shorter |
|---------|---------|
## Prefer Clear, Idiomatic Syntax
Use Laravel helpers and query methods when they communicate intent more directly. Do not shorten code when the result is ambiguous or loses useful type information.
| More verbose | Idiomatic alternative |
| --- | --- |
| `Session::get('cart')` | `session('cart')` |
| `$request->session()->get('cart')` | `session('cart')` |
| `$request->input('name')` | `$request->name` |
| `return Redirect::back()` | `return back()` |
| `Carbon::now()` | `now()` |
| `App::make('Class')` | `app('Class')` |
| `->where('column', '=', 1)` | `->where('column', 1)` |
| `->orderBy('created_at', 'desc')` | `->latest()` |
| `->orderBy('created_at', 'asc')` | `->oldest()` |
| `->first()->name` | `->value('name')` |
| `->first()?->name` | `->value('name')` when only that value is needed |
## Use Laravel String & Array Helpers
Use typed request accessors such as `$request->string()`, `$request->integer()`, and `$request->boolean()` when their coercion matches the operation.
Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them.
## Use Utilities When They Clarify Intent
Laravel's `Str`, `Arr`, `Number`, and `Uri` utilities provide expressive operations and framework-consistent behavior. Prefer them when they are clearer or safer than an equivalent PHP operation, not as an unconditional replacement for every built-in function.
Strings — use `Str` and fluent `Str::of()` over raw PHP:
```php
// Incorrect
$slug = strtolower(str_replace(' ', '-', $title));
$short = substr($text, 0, 100) . '...';
$class = substr(strrchr('App\Models\User', '\\'), 1);
// Correct
$slug = Str::slug($title);
$short = Str::limit($text, 100);
$class = class_basename('App\Models\User');
```
Fluent strings — chain operations for complex transformations:
```php
// Incorrect
$result = strtolower(trim(str_replace('_', '-', $input)));
// Correct
$class = class_basename(User::class);
$result = Str::of($input)->trim()->replace('_', '-')->lower();
```
Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`.
Use `Arr` for dot notation and common transformations:
Arrays — use `Arr` over raw PHP:
```php
// Incorrect
$name = isset($array['user']['name']) ? $array['user']['name'] : 'default';
// Correct
$name = Arr::get($array, 'user.name', 'default');
$public = Arr::only($attributes, ['name', 'email']);
```
Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`.
Use `Number` for localized display formatting rather than values that will be stored or calculated:
Numbers — use `Number` for display formatting:
```php
Number::format(1000000); // "1,000,000"
Number::currency(1500, 'USD'); // "$1,500.00"
Number::abbreviate(1000000); // "1M"
Number::fileSize(1024 * 1024); // "1 MB"
Number::percentage(75.5); // "75.5%"
Number::format(1000000);
Number::currency(1500, 'USD');
Number::fileSize(1024 * 1024);
```
URIs — use `Uri` for URL manipulation:
Use `Uri` when constructing or transforming a uniform resource identifier (URI) benefits from a structured API:
```php
$uri = Uri::of('https://example.com/search')
->withQuery(['q' => 'laravel', 'page' => 1]);
```
Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining.
Check the documentation for the Laravel version supported by the project before using newer utility classes or methods.
Use `search-docs` for the full list of available methods — these helpers are extensive.
## Keep Presentation Code Maintainable
## No Inline JS/CSS in Blade
Prefer the project's asset pipeline, components, and existing conventions for substantial JavaScript and Cascading Style Sheets (CSS). Small page-specific scripts or styles can be reasonable in Blade layouts or stacks; avoid mixing large behavior and style blocks into templates.
Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes.
Pass server data with an encoding mechanism appropriate to its context. For example, Blade's `Js::from()` safely formats data for JavaScript:
Incorrect:
```blade
let article = `{{ json_encode($article) }}`;
<script>
const article = {{ Js::from($article) }};
</script>
```
Correct:
```blade
<button class="js-fav-article" data-article='@json($article)'>{{ $article->name }}</button>
```
Data attributes are useful for small scalar values, but serializing a large model into an attribute can expose unnecessary fields and complicate escaping.
Pass data to JS via data attributes or use a dedicated PHP-to-JS package.
## Write Comments That Explain Why
## No Unnecessary Comments
Prefer clear names and small units of code over comments that merely restate an operation. Add concise comments for non-obvious constraints, tradeoffs, workarounds, regular expressions, or external behavior that the code cannot express by itself. Keep comments accurate when behavior changes.
Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected.
Unhelpful:
Incorrect:
```php
// Check if there are any joins
if (count((array) $builder->getQuery()->joins) > 0)
// Check whether the query has joins.
if (count((array) $builder->getQuery()->joins) > 0) {
// ...
}
```
Correct:
Clearer:
```php
if ($this->hasJoins())
if ($this->hasJoins()) {
// ...
}
```
@@ -1,43 +0,0 @@
# Testing Best Practices
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
## Use Model Assertions Over Raw Database Assertions
Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);`
Correct: `$this->assertModelExists($user);`
More expressive, type-safe, and fails with clearer messages.
## Use Factory States and Sequences
Named states make tests self-documenting. Sequences eliminate repetitive setup.
Incorrect: `User::factory()->create(['email_verified_at' => null]);`
Correct: `User::factory()->unverified()->create();`
## Use `Exceptions::fake()` to Assert Exception Reporting
Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally.
## Call `Event::fake()` After Factory Setup
Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models.
Incorrect: `Event::fake(); $user = User::factory()->create();`
Correct: `$user = User::factory()->create(); Event::fake();`
## Use `recycle()` to Share Relationship Instances Across Factories
Without `recycle()`, nested factories create separate instances of the same conceptual entity.
```php
Ticket::factory()
->recycle(Airline::factory()->create())
->create();
```
@@ -1,75 +1,89 @@
# Validation & Forms Best Practices
# Validation and Forms Best Practices
## Use Form Request Classes
## Extract Validation When It Improves the Boundary
Extract validation from controllers into dedicated Form Request classes.
Use a form request when validation or authorization is substantial, reused, or clearer outside the controller. Inline `$request->validate()` remains appropriate for a small, endpoint-specific rule set.
Incorrect:
```php
public function store(Request $request)
public function store(StorePostRequest $request): RedirectResponse
{
$request->validate([
'title' => 'required|max:255',
'body' => 'required',
]);
$post = Post::create($request->validated());
return redirect()->route('posts.show', $post);
}
```
Correct:
```php
public function store(StorePostRequest $request)
{
Post::create($request->validated());
}
```
A form request's `authorize()` method can enforce access to the operation. Validation establishes the shape and values of input; it does not itself authorize the user.
## Array vs. String Notation for Rules
## Prefer Readable Rule Syntax
Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses.
Array syntax composes cleanly with rule objects and avoids delimiter issues. Prefer it in new code when it improves readability, while following a consistent local style.
```php
// Preferred for new code
'email' => ['required', 'email', Rule::unique('users')],
```
// Follow existing convention if the project uses string notation
String syntax remains valid for simple rules:
```php
'email' => 'required|email|unique:users',
```
## Always Use `validated()`
## Use Only Intended Validated Data
Get only validated data. Never use `$request->all()` for mass operations.
Use `validated()` or `safe()` instead of `$request->all()` when passing request data onward. Then select the fields intended for the operation when the validation rules also cover control fields or nested data.
Unsafe:
Incorrect:
```php
Post::create($request->all());
```
Correct:
Preferred:
```php
Post::create($request->validated());
$post = Post::create($request->safe()->only(['title', 'body']));
```
## Use `Rule::when()` for Conditional Validation
Validated data is not automatically safe for mass assignment. Keep model `$fillable` or `$guarded` rules aligned with the operation, and never add a sensitive attribute to validation merely to make mass assignment convenient.
## Express Conditional Rules Clearly
Use conditional rules such as `Rule::when()`, `required_if`, or `exclude_unless` when they make the condition explicit. Choose the simplest form that remains easy to test.
```php
'company_name' => [
Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']),
'string',
'max:255',
Rule::when(
$this->input('account_type') === 'business',
['required'],
['nullable'],
),
],
```
## Use the `after()` Method for Custom Validation
## Add Cross-Field Validation After Base Rules
Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields.
Use a form request's `after()` method for validation that depends on multiple fields or application state. Avoid expensive queries when prerequisite fields have already failed validation.
```php
public function after(): array
{
return [
function (Validator $validator) {
if ($this->quantity > Product::find($this->product_id)?->stock) {
if ($validator->errors()->hasAny(['product_id', 'quantity'])) {
return;
}
$stock = Product::find($this->integer('product_id'))?->stock;
if ($stock !== null && $this->integer('quantity') > $stock) {
$validator->errors()->add('quantity', 'Not enough stock.');
}
},
];
}
```
Validation against mutable state does not prevent a race between validation and persistence. Enforce inventory, uniqueness, and similar invariants with database constraints, atomic updates, or a database transaction as appropriate.