.cursor/skills/laravel-best-practices/rules/architecture.md
Extract discrete business operations into invokable Action classes.
class CreateOrderAction
{
public function __construct(private InventoryService $inventory) {}
public function execute(array $data): Order
{
$order = Order::create($data);
$this->inventory->reserve($order);
return $order;
}
}
Always use constructor injection. Avoid app() or resolve() inside classes.
Incorrect:
class OrderController extends Controller
{
public function store(StoreOrderRequest $request)
{
$service = app(OrderService::class);
return $service->create($request->validated());
}
}
Correct:
class OrderController extends Controller
{
public function __construct(private OrderService $service) {}
public function store(StoreOrderRequest $request)
{
return $this->service->create($request->validated());
}
}
Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
Incorrect (concrete dependency):
class OrderService
{
public function __construct(private StripeGateway $gateway) {}
}
Correct (interface dependency):
interface PaymentGateway
{
public function charge(int $amount, string $customerId): PaymentResult;
}
class OrderService
{
public function __construct(private PaymentGateway $gateway) {}
}
Bind in a service provider:
$this->app->bind(PaymentGateway::class, StripeGateway::class);
When no explicit order is specified, sort by id or created_at descending. Without an explicit ORDER BY, row order is undefined.
Incorrect:
$posts = Post::paginate();
Correct:
$posts = Post::latest()->paginate();
Prevent race conditions with Cache::lock() or lockForUpdate().
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();
mb_* String FunctionsWhen no Laravel helper exists, prefer mb_strlen, mb_strtolower, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters.
Incorrect:
strlen('José'); // 5 (bytes, not characters)
strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte
Correct:
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'
defer() for Post-Response WorkFor 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.
Incorrect (job overhead for trivial work):
dispatch(new LogPageView($page));
Correct (runs after response, same process):
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.
Context for Request-Scoped DataThe Context facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually.
// In middleware
Context::add('tenant_id', $request->header('X-Tenant-ID'));
// Anywhere later — controllers, jobs, log context
$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.
Concurrency::run() for Parallel ExecutionRun independent operations in parallel using child processes — no async libraries needed.
use Illuminate\Support\Facades\Concurrency;
[$users, $orders] = Concurrency::run([
fn () => User::count(),
fn () => Order::where('status', 'pending')->count(),
]);
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.
Follow Laravel conventions. Don't override defaults unnecessarily.
Incorrect:
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');
}
}
Correct:
class Customer extends Model
{
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class);
}
}