Update
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
# Advanced query best practices
|
||||
|
||||
## Select single relationship values with subqueries
|
||||
|
||||
When you need only one value from a has-many relationship, consider a correlated subquery with `addSelect()` instead of loading the entire relationship. The subquery selects the value as part of the main query, without an additional relationship query.
|
||||
|
||||
```php
|
||||
public function scopeWithLastLoginAt($query): void
|
||||
{
|
||||
$query->addSelect([
|
||||
'last_login_at' => Login::select('created_at')
|
||||
->whereColumn('user_id', 'users.id')
|
||||
->latest()
|
||||
->take(1),
|
||||
])->withCasts(['last_login_at' => 'datetime']);
|
||||
}
|
||||
```
|
||||
|
||||
## Create dynamic relationships with a subquery foreign key
|
||||
|
||||
The same pattern can select a foreign key and expose the selected model through a `belongsTo` relationship. Eager loading that relationship still runs a separate query, but the pattern avoids loading the full has-many collection.
|
||||
|
||||
```php
|
||||
public function lastLogin(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Login::class, 'last_login_id');
|
||||
}
|
||||
|
||||
public function scopeWithLastLogin($query): void
|
||||
{
|
||||
$query->addSelect([
|
||||
'last_login_id' => Login::select('id')
|
||||
->whereColumn('user_id', 'users.id')
|
||||
->latest()
|
||||
->take(1),
|
||||
])->with('lastLogin');
|
||||
}
|
||||
```
|
||||
|
||||
## Combine related counts with conditional aggregates
|
||||
|
||||
Use conditional aggregates to combine several counts over the same filtered data set into one query. Use `toBase()` when you need only scalar values and model hydration adds nothing. Confirm the expression syntax against the application's database engine.
|
||||
|
||||
```php
|
||||
$statuses = Feature::toBase()
|
||||
->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
|
||||
->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
|
||||
->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
|
||||
->first();
|
||||
```
|
||||
|
||||
## Reuse loaded parent models with `setRelation()`
|
||||
|
||||
When a parent and its children are already loaded and code also accesses `$child->parent`, set the inverse relationship to the existing parent instance. Setting the inverse relationship avoids an extra lazy-loading query for each child.
|
||||
|
||||
```php
|
||||
$feature->load('comments.user');
|
||||
$feature->comments->each->setRelation('feature', $feature);
|
||||
```
|
||||
|
||||
## Compare `whereHas()` with an `IN` subquery
|
||||
|
||||
`whereHas()` typically produces an `EXISTS` subquery, while `whereIn()` can express the same filter with an `IN` subquery. Either form can 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.
|
||||
|
||||
Option using `EXISTS`:
|
||||
|
||||
```php
|
||||
$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
|
||||
```
|
||||
|
||||
Option using `IN`:
|
||||
|
||||
```php
|
||||
$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
|
||||
```
|
||||
|
||||
## Measure two simple queries against one complex query
|
||||
|
||||
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.
|
||||
|
||||
## Design composite indexes for the query
|
||||
|
||||
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 the engine uses the index. Verify the query plan.
|
||||
|
||||
```php
|
||||
// Migration
|
||||
$table->index(['last_name', 'first_name']);
|
||||
|
||||
// Query that this index may support
|
||||
User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
|
||||
```
|
||||
|
||||
## Consider a correlated subquery for has-many ordering
|
||||
|
||||
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
|
||||
{
|
||||
$query->orderByDesc(Login::select('created_at')
|
||||
->whereColumn('user_id', 'users.id')
|
||||
->latest()
|
||||
->take(1)
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,219 @@
|
||||
# Architecture best practices
|
||||
|
||||
## Extract focused business operations
|
||||
|
||||
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
|
||||
{
|
||||
public function __construct(private InventoryService $inventory) {}
|
||||
|
||||
public function handle(array $data): Order
|
||||
{
|
||||
$order = Order::create($data);
|
||||
$this->inventory->reserve($order);
|
||||
|
||||
return $order;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Inject required dependencies
|
||||
|
||||
Prefer constructor injection for dependencies required throughout an object's lifetime. Use method injection for a dependency that one controller action, listener, job handler, or other container-invoked method needs. Avoid `app()` and `resolve()` when normal injection can make a dependency explicit.
|
||||
|
||||
Hidden dependency:
|
||||
|
||||
```php
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function store(StoreOrderRequest $request)
|
||||
{
|
||||
$service = app(OrderService::class);
|
||||
|
||||
return $service->create($request->validated());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Injected dependency:
|
||||
|
||||
```php
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function store(StoreOrderRequest $request, OrderService $service)
|
||||
{
|
||||
return $service->create($request->validated());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Depend on contracts at boundaries
|
||||
|
||||
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:
|
||||
|
||||
```php
|
||||
class OrderService
|
||||
{
|
||||
public function __construct(private StripeGateway $gateway) {}
|
||||
}
|
||||
```
|
||||
|
||||
Contract boundary dependency:
|
||||
|
||||
```php
|
||||
interface PaymentGateway
|
||||
{
|
||||
public function charge(int $amount, string $customerId): PaymentResult;
|
||||
}
|
||||
|
||||
class OrderService
|
||||
{
|
||||
public function __construct(private PaymentGateway $gateway) {}
|
||||
}
|
||||
```
|
||||
|
||||
Bind in a service provider:
|
||||
|
||||
```php
|
||||
$this->app->bind(PaymentGateway::class, StripeGateway::class);
|
||||
```
|
||||
|
||||
## Specify a deterministic sort order
|
||||
|
||||
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:
|
||||
|
||||
```php
|
||||
$posts = Post::paginate();
|
||||
```
|
||||
|
||||
Newest first with a stable tie-breaker:
|
||||
|
||||
```php
|
||||
$posts = Post::query()
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->paginate();
|
||||
```
|
||||
|
||||
## Use atomic locks for race conditions
|
||||
|
||||
Use a lock when concurrent processes must run one at a time. `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. Set the lease (`10` below) longer than the callback can ever run. Once the lease expires, another process can acquire the lock while the first process is still working.
|
||||
|
||||
```php
|
||||
Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
|
||||
$order->process();
|
||||
});
|
||||
|
||||
// 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 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 4 characters
|
||||
strtolower('MÜNCHEN'); // Does not lowercase Ü
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```php
|
||||
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'
|
||||
```
|
||||
|
||||
## Use `defer()` for post-response work
|
||||
|
||||
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 Laravel sends the response, but it still runs in the same PHP process.
|
||||
|
||||
Queued and durable:
|
||||
|
||||
```php
|
||||
dispatch(new LogPageView($page));
|
||||
```
|
||||
|
||||
Deferred in the current process:
|
||||
|
||||
```php
|
||||
defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()]));
|
||||
```
|
||||
|
||||
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 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'));
|
||||
|
||||
// Later in the same execution lifecycle
|
||||
$tenantId = Context::get('tenant_id');
|
||||
```
|
||||
|
||||
Laravel adds visible context to log context. It also captures both visible and hidden context and restores them 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 you intend that propagation.
|
||||
|
||||
## Use `Concurrency::run()` for parallel execution
|
||||
|
||||
Run independent operations concurrently through Laravel's configured concurrency driver.
|
||||
|
||||
```php
|
||||
use Illuminate\Support\Facades\Concurrency;
|
||||
|
||||
[$users, $orders] = Concurrency::run([
|
||||
fn () => User::count(),
|
||||
fn () => Order::where('status', 'pending')->count(),
|
||||
]);
|
||||
```
|
||||
|
||||
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 runs closures sequentially and is mainly useful during testing.
|
||||
|
||||
## Follow framework conventions
|
||||
|
||||
Follow Laravel conventions unless the domain or an existing schema requires an override.
|
||||
|
||||
Customized schema:
|
||||
|
||||
```php
|
||||
class Customer extends Model
|
||||
{
|
||||
protected $table = 'Customer';
|
||||
protected $primaryKey = 'customer_id';
|
||||
|
||||
public function roles(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Conventional schema:
|
||||
|
||||
```php
|
||||
class Customer extends Model
|
||||
{
|
||||
public function roles(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Role::class);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# Blade and view best practices
|
||||
|
||||
## Use `$attributes->merge()` in component templates
|
||||
|
||||
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]) }}>
|
||||
{{ $message }}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Use `@pushOnce` for per-component scripts
|
||||
|
||||
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 components for explicit interfaces
|
||||
|
||||
Use a Blade component when a reusable interface benefits from explicit props, an attribute bag, or slots. An include still suits a small partial that intentionally uses the current view data. Pass an explicit data array when implicit variable sharing would hide the partial's dependencies.
|
||||
|
||||
## Share compatible view data with a view composer
|
||||
|
||||
Use a view composer to centralize the data that one or more named Blade views need every time Laravel renders them. 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.
|
||||
|
||||
## Return Blade fragments for partial rendering
|
||||
|
||||
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');
|
||||
```
|
||||
|
||||
## Share parent component props with `@aware`
|
||||
|
||||
Use `@aware` when a nested component needs a prop explicitly passed to an ancestor component. `@aware` does not expose an ancestor's default prop value unless that value was passed through the attribute bag.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Caching best practices
|
||||
|
||||
## Use `Cache::remember()` for cache-aside reads
|
||||
|
||||
`Cache::remember()` implements a cache-aside read without a separate truthiness check. It does not prevent concurrent requests from computing the same missing value. When you must prevent duplicate computation, use an atomic lock.
|
||||
|
||||
The manual version below treats valid falsy values, such as `false` or `0`, as cache misses.
|
||||
|
||||
Incorrect:
|
||||
|
||||
```php
|
||||
$val = Cache::get('stats');
|
||||
if (! $val) {
|
||||
$val = $this->computeStats();
|
||||
Cache::put('stats', $val, 60);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```php
|
||||
$val = Cache::remember('stats', 60, fn () => $this->computeStats());
|
||||
```
|
||||
|
||||
## Consider `Cache::flexible()` for stale-while-revalidate
|
||||
|
||||
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. The refresh is not a durable background job. After the stale period ends, the request recomputes the value synchronously.
|
||||
|
||||
Synchronous expiration:
|
||||
|
||||
```php
|
||||
Cache::remember('users', 300, fn () => User::all());
|
||||
```
|
||||
|
||||
Stale-while-revalidate tradeoff:
|
||||
|
||||
```php
|
||||
Cache::flexible('users', [300, 600], fn () => User::all());
|
||||
```
|
||||
|
||||
In this example, the value is fresh for five minutes. Laravel may serve the stale value until ten minutes after it was cached.
|
||||
|
||||
## Use `Cache::memo()` to avoid redundant hits within an execution
|
||||
|
||||
When code reads the same cache key repeatedly during one request or job, `memo()` decorates a cache store and keeps 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
|
||||
|
||||
Tags group related entries for invalidation without tracking each key. The `file`, `dynamodb`, and `database` drivers do not support cache tags. Confirm that a store supports tags before you choose it.
|
||||
|
||||
```php
|
||||
Cache::tags(['user-1'])->flush();
|
||||
```
|
||||
|
||||
## Use `Cache::add()` for atomic conditional writes
|
||||
|
||||
`add()` atomically writes a value only when the key does not already exist.
|
||||
|
||||
Incorrect:
|
||||
|
||||
```php
|
||||
if (! Cache::has('lock')) {
|
||||
Cache::put('lock', true, 10);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```php
|
||||
Cache::add('lock', true, 10);
|
||||
```
|
||||
|
||||
When you need lock ownership and safe release, use `Cache::lock()` instead of an ordinary cache key.
|
||||
|
||||
## 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
|
||||
{
|
||||
return once(fn () => $this->loadRoles());
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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 it does not replicate data between stores.
|
||||
|
||||
```php
|
||||
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
|
||||
```
|
||||
@@ -0,0 +1,72 @@
|
||||
# Collection best practices
|
||||
|
||||
## Use higher-order messages for simple operations
|
||||
|
||||
Explicit closure:
|
||||
|
||||
```php
|
||||
$users->each(function (User $user) {
|
||||
$user->markAsVip();
|
||||
});
|
||||
```
|
||||
|
||||
Concise equivalent:
|
||||
|
||||
```php
|
||||
$users->each->markAsVip();
|
||||
```
|
||||
|
||||
Higher-order messages work with supported collection methods such as `each`, `map`, `filter`, and `sum`. Use an explicit closure when arguments or nontrivial logic would be clearer.
|
||||
|
||||
## Choose between `cursor()` and `lazy()`
|
||||
|
||||
`cursor()` executes one query and hydrates one model at a time, but it cannot eager load relationships. The database driver's result buffering can still use a lot of memory for very large results. Use it for low-memory, attribute-only iteration when one long-running query is acceptable.
|
||||
|
||||
`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, so updating a column that affects the query can shift rows and make the iteration skip records or process them twice. `lazyById()` paginates by a monotonic key and is safer when you update other columns during iteration. Do not change the pagination key itself while iterating.
|
||||
|
||||
## Use `toQuery()` for bulk operations on collections
|
||||
|
||||
Use `toQuery()` to build a query from the models in an Eloquent collection instead of manually constructing a `whereIn` clause.
|
||||
|
||||
Manual query:
|
||||
|
||||
```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 you don't need those events.
|
||||
|
||||
## Use `#[CollectedBy]` for custom collection classes
|
||||
|
||||
The `#[CollectedBy]` attribute declares the custom collection class without requiring a `newCollection()` override.
|
||||
|
||||
```php
|
||||
#[CollectedBy(UserCollection::class)]
|
||||
class User extends Model {}
|
||||
```
|
||||
@@ -0,0 +1,85 @@
|
||||
# Configuration best practices
|
||||
|
||||
## Read environment variables in configuration files
|
||||
|
||||
Call `env()` only from configuration files. After you cache the configuration, Laravel does not load the application's `.env` file, so read configuration values in application code through `config()`.
|
||||
|
||||
Incorrect:
|
||||
|
||||
```php
|
||||
$key = env('API_KEY');
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```php
|
||||
// config/services.php
|
||||
return [
|
||||
'key' => env('API_KEY'),
|
||||
];
|
||||
|
||||
// Application code
|
||||
$key = config('services.key');
|
||||
```
|
||||
|
||||
## Protect production secrets
|
||||
|
||||
Do not commit plaintext production secrets. Laravel can encrypt an environment file so you can store the encrypted file safely. Deployment platforms can also supply secrets through their native secret stores.
|
||||
|
||||
Incorrect:
|
||||
|
||||
```bash
|
||||
# A plaintext .env file committed to the repository
|
||||
STRIPE_SECRET=<your-stripe-secret>
|
||||
AWS_SECRET_ACCESS_KEY=<your-aws-secret>
|
||||
```
|
||||
|
||||
Encrypted environment file:
|
||||
|
||||
```bash
|
||||
php artisan env:encrypt --env=production --readable
|
||||
php artisan env:decrypt --env=production
|
||||
```
|
||||
|
||||
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()) {
|
||||
// ...
|
||||
}
|
||||
|
||||
if (App::environment('production')) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Name repeated domain values
|
||||
|
||||
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
|
||||
// Repeated literal
|
||||
return $this->type === 'normal';
|
||||
|
||||
// Named domain value
|
||||
return $this->type === self::TYPE_NORMAL;
|
||||
```
|
||||
|
||||
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
|
||||
// In a localized application
|
||||
return back()->with('message', __('app.article_added'));
|
||||
```
|
||||
@@ -0,0 +1,189 @@
|
||||
# Database performance best practices
|
||||
|
||||
## Eager load relationships before iterating
|
||||
|
||||
When code accesses a relationship on many models, eager load the relationship with `with()`. Eager loading avoids the N+1 query pattern: one initial query plus one relationship query per model. Lazy loading is reasonable when the code may not need the relationship or when only one model is involved.
|
||||
|
||||
Lazy-loaded version:
|
||||
|
||||
```php
|
||||
$posts = Post::all();
|
||||
foreach ($posts as $post) {
|
||||
echo $post->author->name;
|
||||
}
|
||||
```
|
||||
|
||||
Eager-loaded version:
|
||||
|
||||
```php
|
||||
$posts = Post::with('author')->get();
|
||||
foreach ($posts as $post) {
|
||||
echo $post->author->name;
|
||||
}
|
||||
```
|
||||
|
||||
Constrain an eager load when the code does not need the large columns. 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, and selecting `posts.id` keeps each related model's primary key:
|
||||
|
||||
```php
|
||||
$users = User::with(['posts' => function ($query) {
|
||||
$query->select('id', 'user_id', 'title')
|
||||
->where('published', true)
|
||||
->latest()
|
||||
->limit(10);
|
||||
}])->get();
|
||||
```
|
||||
|
||||
## Prevent lazy loading in development
|
||||
|
||||
Enable lazy-loading prevention in `AppServiceProvider::boot()` to catch N+1 queries during development.
|
||||
|
||||
```php
|
||||
public function boot(): void
|
||||
{
|
||||
Model::preventLazyLoading(! app()->isProduction());
|
||||
}
|
||||
```
|
||||
|
||||
With prevention enabled, accessing an unloaded relationship throws a `LazyLoadingViolationException` by default. To customize violation handling, use `handleLazyLoadingViolationUsing()`.
|
||||
|
||||
## Select only the columns you need
|
||||
|
||||
When omitting large text, binary, or JSON columns gives a meaningful benefit, select only the columns the operation needs.
|
||||
|
||||
All columns:
|
||||
|
||||
```php
|
||||
$posts = Post::with('author')->get();
|
||||
```
|
||||
|
||||
Selected columns:
|
||||
|
||||
```php
|
||||
$posts = Post::select('id', 'title', 'user_id', 'created_at')
|
||||
->with(['author:id,name,avatar'])
|
||||
->get();
|
||||
```
|
||||
|
||||
When you limit the selected columns, keep 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.
|
||||
|
||||
## Process large data sets incrementally
|
||||
|
||||
When loading the entire result set would exceed the application's practical memory budget, use chunking or lazy iteration.
|
||||
|
||||
Loads the complete result set:
|
||||
|
||||
```php
|
||||
$users = User::all();
|
||||
foreach ($users as $user) {
|
||||
$user->notify(new WeeklyDigest);
|
||||
}
|
||||
```
|
||||
|
||||
Processes bounded chunks:
|
||||
|
||||
```php
|
||||
User::where('subscribed', true)->chunk(200, function ($users) {
|
||||
foreach ($users as $user) {
|
||||
$user->notify(new WeeklyDigest);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Use `chunkById()` when updates can change which rows match the query. Standard `chunk()` uses offset pagination, so result positions can shift as rows change:
|
||||
|
||||
```php
|
||||
User::where('active', false)->chunkById(200, function ($users) {
|
||||
$users->each->delete();
|
||||
});
|
||||
```
|
||||
|
||||
For read-only iteration that uses only model attributes, `cursor()` runs one query and hydrates one model at a time. Some database drivers still buffer the raw results. Use `lazy()` when you need to eager load relationships in chunks. Use `lazyById()` or `chunkById()` when updates can change which rows match the query. See the collection rules for detailed tradeoffs.
|
||||
|
||||
## 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:
|
||||
|
||||
```php
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained();
|
||||
$table->string('status');
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
|
||||
Schema optimized for `WHERE status = ? ORDER BY created_at`:
|
||||
|
||||
```php
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained();
|
||||
$table->string('status');
|
||||
$table->timestamps();
|
||||
$table->index(['status', 'created_at']);
|
||||
});
|
||||
```
|
||||
|
||||
Use production-like data and the database's query-plan tools to confirm the column order of a composite index and whether the index helps. Before you add an index for a foreign key, check whether the database already created one.
|
||||
|
||||
## Count relationships without loading them
|
||||
|
||||
Use `withCount()` when you need only the relationship counts. Loading and hydrating every related model wastes memory.
|
||||
|
||||
Loads related models:
|
||||
|
||||
```php
|
||||
$posts = Post::all();
|
||||
foreach ($posts as $post) {
|
||||
echo $post->comments->count();
|
||||
}
|
||||
```
|
||||
|
||||
Selects relationship counts:
|
||||
|
||||
```php
|
||||
$posts = Post::withCount('comments')->get();
|
||||
foreach ($posts as $post) {
|
||||
echo $post->comments_count;
|
||||
}
|
||||
```
|
||||
|
||||
Conditional counting:
|
||||
|
||||
```php
|
||||
$posts = Post::withCount([
|
||||
'comments',
|
||||
'comments as approved_comments_count' => function ($query) {
|
||||
$query->where('approved', true);
|
||||
},
|
||||
])->get();
|
||||
```
|
||||
|
||||
## Keep queries out of Blade templates
|
||||
|
||||
Prepare data before rendering a Blade template, for example in a controller, a query service, or a view composer. Queries outside the template stay visible and testable.
|
||||
|
||||
Query in the template:
|
||||
|
||||
```blade
|
||||
@foreach (User::all() as $user)
|
||||
{{ $user->profile->name }}
|
||||
@endforeach
|
||||
```
|
||||
|
||||
Data prepared before rendering:
|
||||
|
||||
```php
|
||||
// Controller
|
||||
$users = User::with('profile')->get();
|
||||
|
||||
return view('users.index', compact('users'));
|
||||
```
|
||||
|
||||
```blade
|
||||
@foreach ($users as $user)
|
||||
{{ $user->profile->name }}
|
||||
@endforeach
|
||||
```
|
||||
@@ -0,0 +1,158 @@
|
||||
# Eloquent best practices
|
||||
|
||||
## Define precise relationship types
|
||||
|
||||
Define the relationship that matches the database association, and declare its concrete return type.
|
||||
|
||||
```php
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Comment::class);
|
||||
}
|
||||
|
||||
public function author(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
```
|
||||
|
||||
## Use local scopes for reusable queries
|
||||
|
||||
Extract reusable query constraints into local scopes to avoid duplication.
|
||||
|
||||
Duplicated constraints:
|
||||
|
||||
```php
|
||||
$active = User::where('verified', true)->whereNotNull('activated_at')->get();
|
||||
$articles = Article::whereHas('user', function ($q) {
|
||||
$q->where('verified', true)->whereNotNull('activated_at');
|
||||
})->get();
|
||||
```
|
||||
|
||||
Reusable local scope:
|
||||
|
||||
```php
|
||||
#[Scope]
|
||||
protected function active(Builder $query): Builder
|
||||
{
|
||||
return $query->where('verified', true)->whereNotNull('activated_at');
|
||||
}
|
||||
|
||||
// Usage
|
||||
$active = User::active()->get();
|
||||
$articles = Article::whereHas('user', fn ($q) => $q->active())->get();
|
||||
```
|
||||
|
||||
## Apply global scopes sparingly
|
||||
|
||||
A global scope silently modifies every query on the model, so queries become hard to debug. Prefer local scopes, and reserve global scopes for universal constraints such as soft deletes or multi-tenancy.
|
||||
|
||||
Global scope tradeoff:
|
||||
|
||||
```php
|
||||
class PublishedScope implements Scope
|
||||
{
|
||||
public function apply(Builder $builder, Model $model): void
|
||||
{
|
||||
$builder->where('published', true);
|
||||
}
|
||||
}
|
||||
|
||||
// Admin panels, reports, and jobs now omit drafts unless the scope is removed.
|
||||
```
|
||||
|
||||
Explicit local scope:
|
||||
|
||||
```php
|
||||
#[Scope]
|
||||
protected function published(Builder $query): Builder
|
||||
{
|
||||
return $query->where('published', true);
|
||||
}
|
||||
|
||||
Post::published()->paginate(); // Explicit
|
||||
Post::paginate(); // Admin sees all
|
||||
```
|
||||
|
||||
## Define attribute casts
|
||||
|
||||
Use the `casts()` method for automatic type conversion. If the project convention is the `$casts` property, follow that convention.
|
||||
|
||||
```php
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
'total' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## Cast date and time attributes
|
||||
|
||||
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:
|
||||
|
||||
```blade
|
||||
{{ Carbon::parse($order->ordered_at)->toDateString() }}
|
||||
```
|
||||
|
||||
Model cast:
|
||||
|
||||
```php
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'ordered_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
```blade
|
||||
{{ $order->ordered_at->toDateString() }}
|
||||
{{ $order->ordered_at->format('m-d') }}
|
||||
```
|
||||
|
||||
## Use `whereBelongsTo()` for relationship queries
|
||||
|
||||
`whereBelongsTo()` expresses the relationship constraint without naming its foreign key.
|
||||
|
||||
Foreign key constraint:
|
||||
|
||||
```php
|
||||
Post::where('user_id', $user->id)->get();
|
||||
```
|
||||
|
||||
Relationship-aware constraint:
|
||||
|
||||
```php
|
||||
Post::whereBelongsTo($user)->get();
|
||||
Post::whereBelongsTo($user, 'author')->get();
|
||||
```
|
||||
|
||||
## Keep application queries model-aware
|
||||
|
||||
Prefer Eloquent models and relationships for model-backed application queries. They preserve casts, scopes, and model table configuration. The query builder and raw SQL require table names. Use them when you intend their lower-level behavior.
|
||||
|
||||
Lower-level alternatives:
|
||||
|
||||
```php
|
||||
DB::table('users')->where('active', true)->get();
|
||||
|
||||
$query->join('companies', 'companies.id', '=', 'users.company_id');
|
||||
|
||||
DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
|
||||
```
|
||||
|
||||
Model-aware queries:
|
||||
|
||||
```php
|
||||
User::where('active', true)->get();
|
||||
Order::where('status', 'pending')->get();
|
||||
```
|
||||
|
||||
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. When the schema can change, cover those table references with tests.
|
||||
|
||||
In migrations, use explicit table names rather than application models. Migrations are historical snapshots, but models and their scopes can change after you deploy a migration.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Error handling best practices
|
||||
|
||||
## Choose where to report and render exceptions
|
||||
|
||||
Laravel supports exception-specific methods and centralized handler callbacks. Follow the pattern the project already uses.
|
||||
|
||||
Exception methods keep behavior beside the exception definition:
|
||||
|
||||
```php
|
||||
class InvalidOrderException extends Exception
|
||||
{
|
||||
public function report(): void
|
||||
{
|
||||
// Send the exception to a custom reporter.
|
||||
}
|
||||
|
||||
public function render(Request $request): Response
|
||||
{
|
||||
return response()->view('errors.invalid-order', status: 422);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Centralized callbacks in `bootstrap/app.php` keep the application's exception policy together:
|
||||
|
||||
```php
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
$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);
|
||||
});
|
||||
})
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Mark exceptions the handler should not report
|
||||
|
||||
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 exception reports
|
||||
|
||||
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.
|
||||
|
||||
## Prevent duplicate reports of one exception instance
|
||||
|
||||
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.
|
||||
|
||||
## Define JSON rendering for API routes
|
||||
|
||||
By default, Laravel 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) {
|
||||
return $request->is('api/*') || $request->expectsJson();
|
||||
});
|
||||
```
|
||||
|
||||
## Add context to exception classes
|
||||
|
||||
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
|
||||
{
|
||||
public function context(): array
|
||||
{
|
||||
return ['order_id' => $this->orderId];
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
# Events and notifications best practices
|
||||
|
||||
## Rely on event discovery
|
||||
|
||||
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.
|
||||
|
||||
## Cache event discovery during production deployment
|
||||
|
||||
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
|
||||
|
||||
When you dispatch an event inside a database transaction, `ShouldDispatchAfterCommit` delays dispatch until all open database transactions commit. If a transaction rolls back, Laravel discards the event. The delay affects synchronous and queued listeners. It is not limited to queue timing.
|
||||
|
||||
```php
|
||||
class OrderShipped implements ShouldDispatchAfterCommit {}
|
||||
```
|
||||
|
||||
## Queue slow notifications
|
||||
|
||||
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
|
||||
{
|
||||
use Queueable;
|
||||
}
|
||||
```
|
||||
|
||||
## Dispatch queued notifications after commit
|
||||
|
||||
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. Neither option has a scheduling effect on a synchronous notification.
|
||||
|
||||
```php
|
||||
$user->notify((new InvoicePaid($invoice))->afterCommit());
|
||||
```
|
||||
|
||||
## Route notification channels to dedicated 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
|
||||
|
||||
Avoid creating dummy models to send notifications to arbitrary addresses.
|
||||
|
||||
```php
|
||||
Notification::route('mail', 'admin@example.com')->notify(new SystemAlert());
|
||||
```
|
||||
|
||||
## Implement `HasLocalePreference` on notifiable models
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,157 @@
|
||||
# HTTP client best practices
|
||||
|
||||
## Set explicit timeouts
|
||||
|
||||
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. Retries can multiply the total elapsed time.
|
||||
|
||||
Less resilient:
|
||||
|
||||
```php
|
||||
$response = Http::get('https://api.example.com/users');
|
||||
```
|
||||
|
||||
Preferred:
|
||||
|
||||
```php
|
||||
$response = Http::connectTimeout(3)
|
||||
->timeout(5)
|
||||
->get('https://api.example.com/users');
|
||||
```
|
||||
|
||||
Define shared settings in a macro or a dedicated client:
|
||||
|
||||
```php
|
||||
Http::macro('github', function () {
|
||||
return Http::baseUrl('https://api.github.com')
|
||||
->connectTimeout(3)
|
||||
->timeout(10)
|
||||
->withToken(config('services.github.token'));
|
||||
});
|
||||
|
||||
$response = Http::github()->get('/repos/laravel/framework');
|
||||
```
|
||||
|
||||
## Retry only safe operations
|
||||
|
||||
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.
|
||||
|
||||
Unsafe without an idempotency guarantee:
|
||||
|
||||
```php
|
||||
$response = Http::retry([100, 500, 1000])
|
||||
->post('https://api.example.com/v1/charges', $data);
|
||||
```
|
||||
|
||||
Safe for an idempotent request:
|
||||
|
||||
```php
|
||||
$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
|
||||
|
||||
By default, the HTTP client returns responses for `4xx` and `5xx` status codes instead of throwing. Inspect the expected statuses or call `throw()` before consuming a success payload.
|
||||
|
||||
Unsafe when a success payload is expected:
|
||||
|
||||
```php
|
||||
$user = Http::get('https://api.example.com/users/1')->json();
|
||||
```
|
||||
|
||||
Preferred:
|
||||
|
||||
```php
|
||||
$user = Http::connectTimeout(3)
|
||||
->timeout(5)
|
||||
->get('https://api.example.com/users/1')
|
||||
->throw()
|
||||
->json();
|
||||
```
|
||||
|
||||
When graceful degradation is required, handle expected alternatives explicitly:
|
||||
|
||||
```php
|
||||
$response = Http::connectTimeout(3)
|
||||
->timeout(5)
|
||||
->get('https://api.example.com/users/1');
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
if ($response->notFound()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response->throw();
|
||||
```
|
||||
|
||||
## Pool independent requests
|
||||
|
||||
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.
|
||||
|
||||
```php
|
||||
use Illuminate\Http\Client\Pool;
|
||||
|
||||
$responses = Http::pool(fn (Pool $pool) => [
|
||||
$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']->throw()->json();
|
||||
$posts = $responses['posts']->throw()->json();
|
||||
```
|
||||
|
||||
## Fake HTTP requests in tests
|
||||
|
||||
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.
|
||||
|
||||
```php
|
||||
it('syncs a user from the API', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
Http::fake([
|
||||
'api.example.com/users/1' => Http::response([
|
||||
'name' => 'John Doe',
|
||||
'email' => 'john@example.com',
|
||||
]),
|
||||
]);
|
||||
|
||||
(new UserSyncService)->sync(1);
|
||||
|
||||
Http::assertSent(fn (Request $request) =>
|
||||
$request->url() === 'https://api.example.com/users/1'
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
For example, to test the integration's failure path, fake a connection failure:
|
||||
|
||||
```php
|
||||
Http::fake([
|
||||
'api.example.com/*' => Http::failedConnection(),
|
||||
]);
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
# Mail best practices
|
||||
|
||||
## Queue slow mail delivery
|
||||
|
||||
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()`.
|
||||
|
||||
```php
|
||||
class OrderShipped extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
}
|
||||
```
|
||||
|
||||
Keep mail synchronous when the caller must know immediately whether delivery was accepted, or when no queue worker is available.
|
||||
|
||||
## Dispatch queued mail after commit
|
||||
|
||||
A queue worker can process a queued mailable dispatched during a database transaction 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.
|
||||
|
||||
```php
|
||||
Mail::to($user)->send(
|
||||
(new OrderShipped($order))->afterCommit()
|
||||
);
|
||||
```
|
||||
|
||||
If the transaction rolls back, Laravel does not dispatch an after-commit mailable. The after-commit setting affects only queued mail. It does not defer synchronous delivery.
|
||||
|
||||
## Assert the delivery mode
|
||||
|
||||
Use `Mail::assertQueued()` for queued mailables and `Mail::assertSent()` for synchronously sent mailables.
|
||||
|
||||
Incorrect for a mailable that implements `ShouldQueue`:
|
||||
|
||||
```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 suit conventional transactional messages. A custom HTML and text pair may fit a specialized design better.
|
||||
|
||||
```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.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Migration best practices
|
||||
|
||||
## Generate migrations with Artisan
|
||||
|
||||
Use `php artisan make:migration` to generate the timestamped filename and migration structure.
|
||||
|
||||
```bash
|
||||
php artisan make:migration create_posts_table
|
||||
php artisan make:migration add_slug_to_posts_table
|
||||
```
|
||||
|
||||
## Define foreign-key constraints deliberately
|
||||
|
||||
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();
|
||||
$table->foreignId('author_id')->constrained('users');
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Treat deployed migrations as immutable
|
||||
|
||||
After a migration has run in a shared or production environment, create a new migration for later 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 that appears 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. 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:
|
||||
|
||||
```php
|
||||
$table->string('slug')->unique();
|
||||
```
|
||||
|
||||
Large backfills usually work better as an observable, restartable command or job than inside a schema migration. Small deterministic data changes may be reasonable in a migration when you understand their locking, transaction, and deployment behavior.
|
||||
|
||||
## Mirror defaults only when unsaved models need them
|
||||
|
||||
The database applies a default when it inserts a row, not when you instantiate a model. Mirror the value in the model's `$attributes` only when application code must observe that default before persistence, and keep both definitions in sync.
|
||||
|
||||
```php
|
||||
// Migration
|
||||
$table->string('status')->default('pending');
|
||||
|
||||
// Model
|
||||
protected $attributes = [
|
||||
'status' => 'pending',
|
||||
];
|
||||
```
|
||||
|
||||
## Make rollbacks honest
|
||||
|
||||
Implement `down()` when you can safely reverse the change. 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
|
||||
|
||||
Keep each migration small enough to reason about, deploy, and reverse. Separate long-running backfills from schema changes when the split reduces locks and supports phased deployment. Do not split related operations only to enforce a blanket separation between data definition and data manipulation.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Queue and job best practices
|
||||
|
||||
## Keep reservation time longer than execution time
|
||||
|
||||
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`.
|
||||
|
||||
```php
|
||||
// Job
|
||||
public $timeout = 120;
|
||||
|
||||
// config/queue.php for the connection
|
||||
'retry_after' => 150,
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Back off transient failures
|
||||
|
||||
Use progressively longer delays when a dependency needs time to recover. Do not retry permanent validation or business-rule failures.
|
||||
|
||||
```php
|
||||
class SyncWithStripe implements ShouldQueue
|
||||
{
|
||||
public $tries = 4;
|
||||
|
||||
public $backoff = [1, 5, 10];
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Use unique jobs for dispatch deduplication
|
||||
|
||||
Implement `ShouldBeUnique` when only one queued instance of a logical job should exist. Uniqueness uses a cache lock. It does not replace idempotent processing or a database constraint.
|
||||
|
||||
```php
|
||||
class GenerateInvoice implements ShouldQueue, ShouldBeUnique
|
||||
{
|
||||
public $uniqueFor = 3600;
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return (string) $this->order->id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All dispatching processes must use a shared cache that supports locks. Unique-job constraints do not apply to jobs within batches.
|
||||
|
||||
Use `ShouldBeUniqueUntilProcessing` only when the lock should release immediately before processing begins. Another instance can then 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 changes made to the job during `handle()` are not available in `failed()`.
|
||||
|
||||
```php
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
$this->podcast->update(['status' => 'failed']);
|
||||
|
||||
Log::error('Podcast processing failed', [
|
||||
'podcast_id' => $this->podcast->id,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
## Rate limit external calls
|
||||
|
||||
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
|
||||
{
|
||||
return [new RateLimited('external-api')];
|
||||
}
|
||||
```
|
||||
|
||||
## Batch jobs for group coordination
|
||||
|
||||
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. Laravel does not roll back completed jobs 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 $exception) => Log::error('Import batch failed', [
|
||||
'exception' => $exception,
|
||||
]))
|
||||
->dispatch();
|
||||
```
|
||||
|
||||
## Configure time-based retry limits deliberately
|
||||
|
||||
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. `retryUntil()` takes precedence over attempt-based limits, so you do not need to set `$tries = 0`.
|
||||
|
||||
```php
|
||||
public function retryUntil(): DateTimeInterface
|
||||
{
|
||||
return now()->addHours(4);
|
||||
}
|
||||
```
|
||||
|
||||
## Use Horizon for Redis queue operations
|
||||
|
||||
Laravel Horizon provides monitoring, balancing, metrics, and supervisor configuration for Redis queues. It does not support non-Redis queue drivers.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Routing and controller best practices
|
||||
|
||||
## Use implicit route model binding
|
||||
|
||||
Let Laravel resolve models from route parameters when the default lookup and missing-model behavior fit the endpoint.
|
||||
|
||||
Instead of manual lookup:
|
||||
|
||||
```php
|
||||
public function show(int $id): View
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
|
||||
return view('posts.show', ['post' => $post]);
|
||||
}
|
||||
```
|
||||
|
||||
Use route model binding:
|
||||
|
||||
```php
|
||||
public function show(Post $post): View
|
||||
{
|
||||
return view('posts.show', ['post' => $post]);
|
||||
}
|
||||
```
|
||||
|
||||
## Scope nested bindings
|
||||
|
||||
Use scoped bindings when a nested resource must belong to its parent. Scoped bindings constrain model resolution. They do not replace authorization.
|
||||
|
||||
```php
|
||||
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
|
||||
// The resolved post belongs to the resolved user.
|
||||
})->scopeBindings();
|
||||
```
|
||||
|
||||
## Use resource routes for resourceful actions
|
||||
|
||||
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);
|
||||
|
||||
// Alternatively, for an API-only resource:
|
||||
Route::apiResource('posts', ApiPostController::class);
|
||||
```
|
||||
|
||||
`apiResource()` omits the HTML-oriented `create` and `edit` routes. It does not add an `/api` prefix. That prefix comes from the application's API route configuration.
|
||||
|
||||
## Organize controllers around resources
|
||||
|
||||
By default, organize each controller around one resource and use Laravel's standard resource actions: `index`, `show`, `create`, `store`, `edit`, `update`, and `destroy`. This convention 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:
|
||||
|
||||
```php
|
||||
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
|
||||
{
|
||||
public function store(Podcast $podcast): RedirectResponse
|
||||
{
|
||||
$podcast->publish();
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function destroy(Podcast $podcast): RedirectResponse
|
||||
{
|
||||
$podcast->unpublish();
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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 only to satisfy an arbitrary line limit.
|
||||
|
||||
```php
|
||||
public function store(StorePostRequest $request, CreatePostAction $create): RedirectResponse
|
||||
{
|
||||
$post = $create->handle($request->validated());
|
||||
|
||||
return redirect()->route('posts.show', $post);
|
||||
}
|
||||
```
|
||||
|
||||
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 details.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Task scheduling best practices
|
||||
|
||||
## Prevent unwanted overlap
|
||||
|
||||
Use `withoutOverlapping()` when a second run must not begin while the previous run holds the lock. Use it for variable-duration tasks that are not safe to run concurrently.
|
||||
|
||||
```php
|
||||
Schedule::command('reports:generate')
|
||||
->everyFifteenMinutes()
|
||||
->withoutOverlapping(30);
|
||||
```
|
||||
|
||||
The optional value is the lock expiration time in minutes, not the task timeout. Choose the value carefully. The default is 24 hours, and an expiration that is too short can permit overlap while the first task still runs. Clear stale locks with `php artisan schedule:clear-cache`. The task itself should still tolerate retries and partial execution where practical.
|
||||
|
||||
## Run a task on one server
|
||||
|
||||
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`.
|
||||
|
||||
```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. Give background processes logging and failure monitoring.
|
||||
|
||||
## Restrict tasks by environment
|
||||
|
||||
Use `environments()` when a task should run only in named application environments. Treat `environments()` 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 share frequency or constraints.
|
||||
|
||||
```php
|
||||
Schedule::daily()
|
||||
->onOneServer()
|
||||
->timezone('America/New_York')
|
||||
->group(function () {
|
||||
Schedule::command('emails:send --force');
|
||||
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.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Security best practices
|
||||
|
||||
## Control mass assignment
|
||||
|
||||
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.
|
||||
|
||||
```php
|
||||
class User extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
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 protected actions
|
||||
|
||||
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.
|
||||
|
||||
```php
|
||||
public function update(UpdatePostRequest $request, Post $post): RedirectResponse
|
||||
{
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
$post->update($request->validated());
|
||||
|
||||
return redirect()->route('posts.show', $post);
|
||||
}
|
||||
```
|
||||
|
||||
Authorization may instead live in the form request:
|
||||
|
||||
```php
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('update', $this->route('post')) ?? false;
|
||||
}
|
||||
```
|
||||
|
||||
Public actions intentionally available to everyone do not need a redundant authorization check.
|
||||
|
||||
## 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();
|
||||
User::whereRaw('LOWER(name) = ?', [$request->string('name')->lower()->toString()])->get();
|
||||
```
|
||||
|
||||
## Escape output in its context
|
||||
|
||||
Blade's `{{ }}` syntax HTML-escapes output. Use `{!! !!}` only for content sanitized for the exact HTML context where it renders. Escaping rules differ for HTML, URLs, JavaScript, and Cascading Style Sheets.
|
||||
|
||||
Incorrect for untrusted content:
|
||||
|
||||
```blade
|
||||
{!! $user->bio !!}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```blade
|
||||
{{ $user->bio }}
|
||||
```
|
||||
|
||||
## Apply cross-site request forgery protection
|
||||
|
||||
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.
|
||||
|
||||
```blade
|
||||
<form method="POST" action="/posts">
|
||||
@csrf
|
||||
<input type="text" name="title">
|
||||
</form>
|
||||
```
|
||||
|
||||
Inertia applications commonly use Axios, which returns the encrypted `XSRF-TOKEN` cookie in the `X-XSRF-TOKEN` header. When you use another HTTP client, confirm equivalent configuration. Do not disable CSRF protection only to fix a token mismatch.
|
||||
|
||||
## 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. An account identifier alone can enable targeted denial of service.
|
||||
|
||||
```php
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
return Limit::perMinute(5)->by(Str::transliterate(
|
||||
Str::lower($request->string('email')).'|'.$request->ip()
|
||||
));
|
||||
});
|
||||
|
||||
Route::post('/login', LoginController::class)->middleware('throttle:login');
|
||||
```
|
||||
|
||||
Rate limiting reduces abuse. It does not replace authentication, authorization, or upstream denial-of-service protection.
|
||||
|
||||
## Validate and store uploads safely
|
||||
|
||||
Validate the expected content type, the size, and the dimensions where relevant. 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. Do not use it by itself.
|
||||
|
||||
```php
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
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');
|
||||
```
|
||||
|
||||
## Keep secrets out of application code
|
||||
|
||||
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. See the configuration rules for encrypted environment files and external secret stores.
|
||||
|
||||
## Audit dependencies
|
||||
|
||||
Run `composer audit` regularly and in continuous integration. Review findings for exploitability and update or mitigate affected packages promptly.
|
||||
|
||||
```bash
|
||||
composer audit
|
||||
```
|
||||
|
||||
## Encrypt sensitive attributes when appropriate
|
||||
|
||||
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. You cannot meaningfully query encrypted values. Store them in a `TEXT` or larger column because ciphertext length varies.
|
||||
|
||||
```php
|
||||
class Integration extends Model
|
||||
{
|
||||
protected $hidden = ['api_key', 'api_secret'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'api_key' => 'encrypted',
|
||||
'api_secret' => 'encrypted',
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,110 @@
|
||||
# Convention and style best practices
|
||||
|
||||
## Follow project naming conventions
|
||||
|
||||
Prefer Laravel's conventions in new code, but preserve an established project convention unless a coordinated rename is worthwhile.
|
||||
|
||||
| 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, with 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` |
|
||||
|
||||
## 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')` |
|
||||
| `return Redirect::back()` | `return back()` |
|
||||
| `Carbon::now()` | `now()` |
|
||||
| `->where('column', '=', 1)` | `->where('column', 1)` |
|
||||
| `->orderBy('created_at', 'desc')` | `->latest()` |
|
||||
| `->orderBy('created_at', 'asc')` | `->oldest()` |
|
||||
| `->first()?->name` | `->value('name')` when only that value is needed |
|
||||
|
||||
Use typed request accessors such as `$request->string()`, `$request->integer()`, and `$request->boolean()` when their coercion matches the operation.
|
||||
|
||||
## 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 a replacement for every built-in function.
|
||||
|
||||
```php
|
||||
$slug = Str::slug($title);
|
||||
$short = Str::limit($text, 100);
|
||||
$class = class_basename(User::class);
|
||||
$result = Str::of($input)->trim()->replace('_', '-')->lower();
|
||||
```
|
||||
|
||||
Use `Arr` for dot notation and common transformations:
|
||||
|
||||
```php
|
||||
$name = Arr::get($array, 'user.name', 'default');
|
||||
$public = Arr::only($attributes, ['name', 'email']);
|
||||
```
|
||||
|
||||
Use `Number` for localized display formatting, not for values you store or calculate:
|
||||
|
||||
```php
|
||||
Number::format(1000000);
|
||||
Number::currency(1500, 'USD');
|
||||
Number::fileSize(1024 * 1024);
|
||||
```
|
||||
|
||||
Use `Uri` when a structured API helps you build or transform a uniform resource identifier (URI):
|
||||
|
||||
```php
|
||||
$uri = Uri::of('https://example.com/search')
|
||||
->withQuery(['q' => 'laravel', 'page' => 1]);
|
||||
```
|
||||
|
||||
Before using newer utility classes or methods, check the documentation for the project's Laravel version.
|
||||
|
||||
## Keep presentation code maintainable
|
||||
|
||||
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.
|
||||
|
||||
Pass server data with an encoding that fits its context. For example, Blade's `Js::from()` safely formats data for JavaScript:
|
||||
|
||||
```blade
|
||||
<script>
|
||||
const article = {{ Js::from($article) }};
|
||||
</script>
|
||||
```
|
||||
|
||||
Data attributes are useful for small scalar values, but serializing a large model into an attribute can expose unnecessary fields and complicate escaping.
|
||||
|
||||
## Write comments that explain why
|
||||
|
||||
Prefer clear names and small units of code over comments that 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.
|
||||
|
||||
Unhelpful:
|
||||
|
||||
```php
|
||||
// Check whether the query has joins.
|
||||
if (count((array) $builder->getQuery()->joins) > 0) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Clearer:
|
||||
|
||||
```php
|
||||
if ($this->hasJoins()) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
# Validation and forms best practices
|
||||
|
||||
## Extract validation when it improves the boundary
|
||||
|
||||
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.
|
||||
|
||||
```php
|
||||
public function store(StorePostRequest $request): RedirectResponse
|
||||
{
|
||||
$post = Post::create($request->validated());
|
||||
|
||||
return redirect()->route('posts.show', $post);
|
||||
}
|
||||
```
|
||||
|
||||
A form request's `authorize()` method can enforce access to the operation. Validation checks the shape and values of the input. Validation alone does not authorize the user.
|
||||
|
||||
## Prefer readable rule syntax
|
||||
|
||||
Array syntax combines with rule objects and avoids delimiter problems. Prefer array syntax in new code when it improves readability, and keep the local style consistent.
|
||||
|
||||
```php
|
||||
'email' => ['required', 'email', Rule::unique('users')],
|
||||
```
|
||||
|
||||
String syntax remains valid for simple rules:
|
||||
|
||||
```php
|
||||
'email' => 'required|email|unique:users',
|
||||
```
|
||||
|
||||
## Use only the validated data you intend
|
||||
|
||||
Use `validated()` or `safe()` instead of `$request->all()` when passing request data onward. When the validation rules also cover control fields or nested data, select only the fields intended for the operation.
|
||||
|
||||
Unsafe:
|
||||
|
||||
```php
|
||||
Post::create($request->all());
|
||||
```
|
||||
|
||||
Preferred:
|
||||
|
||||
```php
|
||||
$post = Post::create($request->safe()->only(['title', 'body']));
|
||||
```
|
||||
|
||||
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 only 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' => [
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::when(
|
||||
$this->input('account_type') === 'business',
|
||||
['required'],
|
||||
['nullable'],
|
||||
),
|
||||
],
|
||||
```
|
||||
|
||||
## Add cross-field validation after base rules
|
||||
|
||||
Use a form request's `after()` method for validation that depends on multiple fields or application state. Skip expensive queries when prerequisite fields already failed validation.
|
||||
|
||||
```php
|
||||
public function after(): array
|
||||
{
|
||||
return [
|
||||
function (Validator $validator) {
|
||||
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.
|
||||
Reference in New Issue
Block a user