# PHP/Laravel Standards > **⚠️ MAINTENANCE:** This file is indexed in `dev-team/skills/shared-patterns/standards-coverage-table.md`. > When adding/removing `## ` sections, follow FOUR-FILE UPDATE RULE in CLAUDE.md: (1) edit standards file, (2) update TOC, (3) update standards-coverage-table.md, (4) update agent file. This file defines the specific standards for PHP/Laravel backend development. > **Reference**: Always consult `docs/PROJECT_RULES.md` for common project standards. --- ## Table of Contents | # | Section | Description | |----|---------|-------------| | 1 | [Version](#version) | PHP 8.2+, Laravel 11+ | | 2 | [Core Dependencies](#core-dependencies) | Required Composer packages | | 3 | [Frameworks & Libraries](#frameworks--libraries) | Laravel, Pest, PHPStan, etc. | | 4 | [Configuration](#configuration) | Environment variable handling | | 5 | [Database Naming Convention (snake_case)](#database-naming-convention-snake_case-mandatory) | Table and column naming | | 6 | [Database Migrations](#database-migrations-mandatory) | Laravel migrations | | 7 | [License Headers](#license-headers-mandatory) | Source file headers | | 8 | [Eloquent Patterns](#eloquent-patterns-mandatory) | Models, scopes, relationships | | 9 | [Dependency Management](#dependency-management-mandatory) | Version pinning, security | | 10 | [Observability](#observability) | OpenTelemetry integration | | 11 | [Bootstrap](#bootstrap) | Service Provider registration | | 12 | [Graceful Shutdown Patterns](#graceful-shutdown-patterns-mandatory) | Queue worker signals | | 13 | [Health Checks](#health-checks-mandatory) | /health vs /ready | | 14 | [Connection Management](#connection-management-mandatory) | DB, Redis, Queue | | 15 | [Authentication Integration](#authentication-integration-mandatory) | Sanctum, Passport, Guards | | 16 | [Secret Redaction Patterns](#secret-redaction-patterns-mandatory) | Credential leak prevention | | 17 | [SQL Safety](#sql-safety-mandatory) | Injection prevention | | 18 | [HTTP Security Headers](#http-security-headers-mandatory) | CSRF, CORS, headers | | 19 | [Data Transformation](#data-transformation-mandatory) | API Resources, DTOs | | 20 | [Error Codes Convention](#error-codes-convention-mandatory) | Service-prefixed codes | | 21 | [Error Handling](#error-handling) | Exception hierarchy | | 22 | [Exit/Fatal Location Rules](#exitfatal-location-rules-mandatory) | die()/exit() FORBIDDEN | | 23 | [Function Design](#function-design-mandatory) | Single responsibility | | 24 | [File Organization](#file-organization-mandatory) | Max 200-300 lines | | 25 | [JSON Naming Convention (camelCase)](#json-naming-convention-camelcase-mandatory) | API response fields | | 26 | [Pagination Patterns](#pagination-patterns) | Cursor & offset | | 27 | [HTTP Status Code Consistency](#http-status-code-consistency-mandatory) | 201 create, 200 update | | 28 | [OpenAPI Documentation](#openapi-documentation-mandatory) | L5-Swagger or Scramble | | 29 | [Controller Constructor Pattern](#controller-constructor-pattern-mandatory) | DI via constructor | | 30 | [Input Validation](#input-validation-mandatory) | Form Requests | | 31 | [Testing](#testing) | Pest + PHPUnit patterns | | 32 | [Logging](#logging) | Structured JSON, Monolog | | 33 | [Linting](#linting) | PHP CS Fixer, PHPStan | | 34 | [Production Config Validation](#production-config-validation-mandatory) | config:cache, route:cache | | 35 | [Container Security](#container-security-conditional) | Non-root, PHP-FPM | | 36 | [Architecture Patterns](#architecture-patterns) | Hexagonal/Lerian | | 37 | [Directory Structure](#directory-structure) | Laravel hexagonal | | 38 | [N+1 Query Detection](#n1-query-detection-mandatory) | preventLazyLoading() | | 39 | [Performance Patterns](#performance-patterns-mandatory) | Query optimization | | 40 | [RabbitMQ Worker Pattern](#rabbitmq-worker-pattern) | php-amqplib, Queues | | 41 | [RabbitMQ Reconnection Strategy](#rabbitmq-reconnection-strategy-mandatory) | Consumer reconnection | | 42 | [Always-Valid Domain Model](#always-valid-domain-model-mandatory) | Constructor validation | | 43 | [Idempotency Patterns](#idempotency-patterns-mandatory-for-transaction-apis) | Redis-based keys | | 44 | [Multi-Tenant Patterns](#multi-tenant-patterns-conditional) | stancl/tenancy | | 45 | [Rate Limiting](#rate-limiting-mandatory) | Laravel RateLimiter | | 46 | [CORS Configuration](#cors-configuration-mandatory) | config/cors.php | **Meta-sections (not checked by agents):** - [Checklist](#checklist) - Self-verification before deploying --- ## Version - **PHP**: 8.2+ (MANDATORY). Use `declare(strict_types=1);` in ALL files. - **Laravel**: 11+ (MANDATORY) - **Composer**: 2+ (MANDATORY) ```php env('USER_LIMIT', 100), ]; ``` **Detection:** ```bash grep -rn "env(" app/ --include="*.php" | grep -v "config/" ``` --- ## Database Naming Convention (snake_case) (MANDATORY) | Element | Convention | Example | |---------|-----------|---------| | Tables | snake_case, plural | `user_accounts` | | Columns | snake_case | `created_at`, `tenant_id` | | Foreign keys | `{table_singular}_id` | `user_id` | | Pivot tables | alphabetical, singular | `role_user` | | Indexes | `{table}_{columns}_index` | `users_email_index` | **Detection:** ```bash grep -rn "Schema::create\|->table(" database/migrations/ --include="*.php" ``` --- ## Database Migrations (MANDATORY) ```php // ✅ CORRECT - Migration with rollback safety public function up(): void { Schema::create('transactions', function (Blueprint $table) { $table->uuid('id')->primary(); $table->foreignUuid('tenant_id')->constrained()->cascadeOnDelete(); $table->foreignUuid('user_id')->constrained(); $table->decimal('amount', 15, 2); $table->string('currency', 3)->default('BRL'); $table->string('status')->default('pending'); $table->timestamps(); $table->softDeletes(); $table->index(['tenant_id', 'status']); $table->index(['user_id', 'created_at']); }); } public function down(): void { Schema::dropIfExists('transactions'); } ``` **Rules:** - Every `up()` MUST have a corresponding `down()` - Use UUIDs for primary keys (MANDATORY for multi-tenant) - Always add indexes for foreign keys and frequently queried columns - Use `$table->timestamps()` and `$table->softDeletes()` when appropriate **Detection:** ```bash grep -rn "function up" database/migrations/ --include="*.php" | wc -l grep -rn "function down" database/migrations/ --include="*.php" | wc -l ``` --- ## License Headers (MANDATORY) All PHP source files MUST include a license header: ```php 'decimal:2', 'metadata' => 'array', 'is_active' => 'boolean', 'processed_at' => 'datetime', 'status' => TransactionStatus::class, // Enum cast ]; } ``` ### Query Scopes ```php // ✅ Named scopes for reusable queries public function scopeActive(Builder $query): Builder { return $query->where('status', 'active'); } public function scopeForTenant(Builder $query, string $tenantId): Builder { return $query->where('tenant_id', $tenantId); } // Usage: Transaction::active()->forTenant($tenantId)->get(); ``` ### Relationships ```php // ✅ Always define return types public function user(): BelongsTo { return $this->belongsTo(User::class); } public function items(): HasMany { return $this->hasMany(TransactionItem::class); } ``` ### Soft Deletes ```php use Illuminate\Database\Eloquent\SoftDeletes; class Transaction extends Model { use SoftDeletes; } ``` **Detection:** ```bash grep -rn "protected \$fillable\|protected \$guarded" app/Models/ --include="*.php" grep -rn "function casts" app/Models/ --include="*.php" ``` --- ## Dependency Management (MANDATORY) - **MUST** commit `composer.lock` to version control - **MUST** use `roave/security-advisories` to block vulnerable packages - **MUST** pin major versions in `composer.json` - **MUST** run `composer audit` in CI/CD ```json { "require": { "php": "^8.2", "laravel/framework": "^11.0", "predis/predis": "^2.0" }, "require-dev": { "roave/security-advisories": "dev-latest" } } ``` **Detection:** ```bash test -f composer.lock && echo "OK" || echo "MISSING composer.lock" composer audit grep "roave/security-advisories" composer.json ``` --- ## Observability ### OpenTelemetry Integration ```php // app/Providers/TelemetryServiceProvider.php class TelemetryServiceProvider extends ServiceProvider { public function register(): void { $this->app->singleton(TracerInterface::class, function () { $exporter = new OtlpHttpTransportFactory()->create( config('telemetry.endpoint'), 'application/x-protobuf' ); $tracerProvider = TracerProvider::builder() ->addSpanProcessor( new BatchSpanProcessor($exporter) ) ->setResource( ResourceInfoFactory::defaultResource()->merge( ResourceInfo::create(Attributes::create([ 'service.name' => config('app.name'), 'service.version' => config('app.version'), ])) ) ) ->build(); return $tracerProvider->getTracer('app'); }); } } ``` ### Span Naming Convention (MANDATORY) Pattern: `app.{layer}.{operation}` | Layer | Example | |-------|---------| | Controller | `app.controller.store_transaction` | | Service | `app.service.process_payment` | | Repository | `app.repository.find_by_id` | | Queue | `app.queue.process_notification` | | HTTP Client | `app.http.fetch_exchange_rate` | **Detection:** ```bash grep -rn "spanBuilder\|startSpan" app/ --include="*.php" ``` --- ## Bootstrap ### Service Provider Registration Order ```php // bootstrap/providers.php (Laravel 11+) return [ // 1. Infrastructure (logging, telemetry, config) App\Providers\TelemetryServiceProvider::class, // 2. Domain (repositories, services) App\Providers\RepositoryServiceProvider::class, // 3. Application (event listeners, policies) App\Providers\EventServiceProvider::class, // 4. Presentation (routes, middleware) App\Providers\RouteServiceProvider::class, ]; ``` ### Deferred Providers ```php class HeavyServiceProvider extends ServiceProvider implements DeferrableProvider { public function provides(): array { return [HeavyService::class]; } } ``` --- ## Graceful Shutdown Patterns (MANDATORY) ```php // For queue workers // config/queue.php 'connections' => [ 'redis' => [ 'driver' => 'redis', 'connection' => 'default', 'queue' => env('REDIS_QUEUE', 'default'), 'retry_after' => 90, 'block_for' => null, ], ], // Custom worker with signal handling class CustomWorker { private bool $shouldStop = false; public function __construct() { pcntl_signal(SIGTERM, fn () => $this->shouldStop = true); pcntl_signal(SIGINT, fn () => $this->shouldStop = true); } public function run(): void { while (!$this->shouldStop) { pcntl_signal_dispatch(); $this->processNext(); } $this->cleanup(); } } ``` **Detection:** ```bash grep -rn "pcntl_signal\|SIGTERM\|SIGINT" app/ --include="*.php" grep -rn "retry_after" config/queue.php ``` --- ## Health Checks (MANDATORY) ```php // routes/api.php Route::get('/health', [HealthController::class, 'liveness']); Route::get('/ready', [HealthController::class, 'readiness']); // app/Http/Controllers/HealthController.php class HealthController extends Controller { public function liveness(): JsonResponse { return response()->json(['status' => 'ok']); } public function readiness(): JsonResponse { $checks = [ 'database' => $this->checkDatabase(), 'redis' => $this->checkRedis(), 'queue' => $this->checkQueue(), ]; $healthy = !in_array(false, $checks, true); return response()->json([ 'status' => $healthy ? 'ready' : 'not_ready', 'checks' => $checks, ], $healthy ? 200 : 503); } private function checkDatabase(): bool { try { DB::connection()->getPdo(); return true; } catch (\Throwable) { return false; } } private function checkRedis(): bool { try { Cache::store('redis')->get('health_check'); return true; } catch (\Throwable) { return false; } } private function checkQueue(): bool { try { Queue::size('default'); return true; } catch (\Throwable) { return false; } } } ``` **Detection:** ```bash grep -rn "/health\|/ready" routes/ --include="*.php" ``` --- ## Connection Management (MANDATORY) ```php // config/database.php 'pgsql' => [ 'driver' => 'pgsql', 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '5432'), 'database' => env('DB_DATABASE', 'app'), 'username' => env('DB_USERNAME', 'app'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', 'search_path' => 'public', 'sslmode' => env('DB_SSLMODE', 'prefer'), // Pool configuration 'options' => [ PDO::ATTR_PERSISTENT => false, PDO::ATTR_TIMEOUT => 5, ], ], // config/database.php (Redis) 'redis' => [ 'client' => env('REDIS_CLIENT', 'predis'), 'default' => [ 'host' => env('REDIS_HOST', '127.0.0.1'), 'password' => env('REDIS_PASSWORD'), 'port' => env('REDIS_PORT', 6379), 'database' => env('REDIS_DB', 0), 'read_timeout' => 5, 'timeout' => 5, ], ], ``` --- ## Authentication Integration (MANDATORY) ```php // Using Laravel Sanctum // config/auth.php 'guards' => [ 'api' => [ 'driver' => 'sanctum', 'provider' => 'users', ], ], // Middleware Route::middleware('auth:sanctum')->group(function () { Route::apiResource('transactions', TransactionController::class); }); // Policies (MANDATORY for authorization) class TransactionPolicy { public function view(User $user, Transaction $transaction): bool { return $user->tenant_id === $transaction->tenant_id; } public function create(User $user): bool { return $user->hasPermission('transactions.create'); } } // Gates for simple checks Gate::define('admin', fn (User $user) => $user->role === 'admin'); ``` **Detection:** ```bash grep -rn "auth:sanctum\|auth:api\|->authorize" app/ routes/ --include="*.php" grep -rn "class.*Policy" app/Policies/ --include="*.php" ``` --- ## Secret Redaction Patterns (MANDATORY) ```php // config/logging.php - Redact sensitive fields 'processors' => [ App\Logging\SensitiveDataProcessor::class, ], // app/Logging/SensitiveDataProcessor.php class SensitiveDataProcessor { private const SENSITIVE_KEYS = [ 'password', 'token', 'secret', 'authorization', 'api_key', 'credit_card', 'ssn', 'cpf', ]; public function __invoke(LogRecord $record): LogRecord { $context = $record->context; foreach (self::SENSITIVE_KEYS as $key) { if (isset($context[$key])) { $context[$key] = '***REDACTED***'; } } return $record->with(context: $context); } } // ⛔ FORBIDDEN: Logging sensitive data Log::info('User login', ['password' => $password]); // FORBIDDEN Log::info('API call', ['token' => $token]); // FORBIDDEN // ✅ CORRECT Log::info('User login', ['user_id' => $user->id]); ``` **Detection:** ```bash grep -rn "Log::\|logger()->" app/ --include="*.php" | grep -i "password\|token\|secret\|key" ``` --- ## SQL Safety (MANDATORY) ```php // ⛔ FORBIDDEN - Raw SQL without parameterization DB::select("SELECT * FROM users WHERE email = '$email'"); // SQL INJECTION // ✅ CORRECT - Parameterized queries DB::select('SELECT * FROM users WHERE email = ?', [$email]); // ✅ CORRECT - Eloquent (safe by default) User::where('email', $email)->first(); // ✅ CORRECT - Query Builder with bindings DB::table('users') ->where('email', $email) ->where('status', 'active') ->first(); // ⚠️ CAREFUL - Raw expressions need manual escaping DB::table('users') ->whereRaw('LOWER(email) = ?', [strtolower($email)]) // OK - parameterized ->first(); ``` **Detection:** ```bash grep -rn "DB::select\|DB::statement\|DB::unprepared\|->whereRaw\|->selectRaw" app/ --include="*.php" ``` --- ## HTTP Security Headers (MANDATORY) ```php // app/Http/Middleware/SecurityHeaders.php class SecurityHeaders { public function handle(Request $request, Closure $next): Response { $response = $next($request); $response->headers->set('X-Content-Type-Options', 'nosniff'); $response->headers->set('X-Frame-Options', 'DENY'); $response->headers->set('X-XSS-Protection', '1; mode=block'); $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); return $response; } } // Register in bootstrap/app.php ->withMiddleware(function (Middleware $middleware) { $middleware->append(SecurityHeaders::class); }) ``` **Detection:** ```bash grep -rn "X-Content-Type-Options\|X-Frame-Options" app/Http/Middleware/ --include="*.php" ``` --- ## Data Transformation (MANDATORY) ```php // ✅ CORRECT - API Resources for output transformation class TransactionResource extends JsonResource { public function toArray(Request $request): array { return [ 'id' => $this->id, 'userId' => $this->user_id, // camelCase for JSON 'amount' => (string) $this->amount, 'currency' => $this->currency, 'status' => $this->status, 'createdAt' => $this->created_at->toIso8601String(), 'updatedAt' => $this->updated_at->toIso8601String(), 'user' => new UserResource($this->whenLoaded('user')), 'items' => TransactionItemResource::collection($this->whenLoaded('items')), ]; } } // ✅ DTOs for input final readonly class CreateTransactionDTO { public function __construct( public string $userId, public float $amount, public string $currency, public array $metadata = [], ) {} public static function fromRequest(StoreTransactionRequest $request): self { return new self( userId: $request->validated('user_id'), amount: (float) $request->validated('amount'), currency: $request->validated('currency'), metadata: $request->validated('metadata', []), ); } } ``` --- ## Error Codes Convention (MANDATORY) Pattern: `{SERVICE}-{NUMBER}` | Service | Prefix | Example | |---------|--------|---------| | Auth | `AUTH` | `AUTH-001: Invalid credentials` | | Payment | `PAY` | `PAY-001: Insufficient balance` | | Transaction | `TXN` | `TXN-001: Transaction not found` | | User | `USR` | `USR-001: User not found` | | Tenant | `TNT` | `TNT-001: Tenant not found` | ```php enum ErrorCode: string { case AUTH_INVALID_CREDENTIALS = 'AUTH-001'; case AUTH_TOKEN_EXPIRED = 'AUTH-002'; case PAY_INSUFFICIENT_BALANCE = 'PAY-001'; case TXN_NOT_FOUND = 'TXN-001'; } ``` --- ## Error Handling ### Exception Hierarchy ```php // Base domain exception abstract class DomainException extends \RuntimeException { public function __construct( string $message, public readonly ErrorCode $errorCode, public readonly array $context = [], int $code = 0, ?\Throwable $previous = null, ) { parent::__construct($message, $code, $previous); } } // Specific exceptions class EntityNotFoundException extends DomainException { public static function forTransaction(string $id): self { return new self( message: "Transaction not found: {$id}", errorCode: ErrorCode::TXN_NOT_FOUND, context: ['transaction_id' => $id], code: 404, ); } } class InsufficientBalanceException extends DomainException { public static function forAmount(float $requested, float $available): self { return new self( message: "Insufficient balance: requested {$requested}, available {$available}", errorCode: ErrorCode::PAY_INSUFFICIENT_BALANCE, context: ['requested' => $requested, 'available' => $available], code: 422, ); } } ``` ### Exception Handler ```php // bootstrap/app.php ->withExceptions(function (Exceptions $exceptions) { $exceptions->render(function (DomainException $e, Request $request) { if ($request->expectsJson()) { return response()->json([ 'error' => [ 'code' => $e->errorCode->value, 'message' => $e->getMessage(), ], ], $e->getCode() ?: 500); } }); }) ``` --- ## Exit/Fatal Location Rules (MANDATORY) - `die()` in any application code - `exit()` in any application code - `dd()` in committed code (dev-only, never commit) - `dump()` in committed code - `var_dump()` anywhere - `print_r()` for debugging - `echo` for logging **Detection:** ```bash grep -rn "die(\|exit(\|dd(\|dump(\|var_dump(\|print_r(" app/ --include="*.php" ``` --- ## Function Design (MANDATORY) - **Max 4 parameters** per function/method (use DTOs for more) - **Single responsibility** — one function does one thing - **Early returns** — reduce nesting - **Max 30 lines** per function (excluding docblocks) ```php // ❌ WRONG - Too many parameters public function createTransaction( string $userId, float $amount, string $currency, string $description, array $metadata, string $tenantId ): Transaction { ... } // ✅ CORRECT - Use DTO public function createTransaction(CreateTransactionDTO $dto): Transaction { ... } // ✅ CORRECT - Early returns public function processPayment(Payment $payment): PaymentResult { if ($payment->isExpired()) { throw PaymentExpiredException::forPayment($payment->id); } if ($payment->amount <= 0) { throw InvalidAmountException::forAmount($payment->amount); } return $this->gateway->charge($payment); } ``` --- ## File Organization (MANDATORY) - **Max 200-300 lines** per file - **One class per file** (PSR-4) - **Group by domain**, not by type **Detection:** ```bash find app/ -name "*.php" -exec wc -l {} + | sort -rn | head -20 ``` --- ## JSON Naming Convention (camelCase) (MANDATORY) API responses MUST use camelCase for JSON field names: ```php // ✅ CORRECT - API Resource with camelCase class UserResource extends JsonResource { public function toArray(Request $request): array { return [ 'id' => $this->id, 'firstName' => $this->first_name, // camelCase 'lastName' => $this->last_name, // camelCase 'emailAddress' => $this->email, // camelCase 'createdAt' => $this->created_at->toIso8601String(), ]; } } ``` --- ## Pagination Patterns ### Offset Pagination ```php // Controller public function index(Request $request): AnonymousResourceCollection { $perPage = min((int) $request->query('per_page', 15), 100); $transactions = Transaction::query() ->forTenant($request->user()->tenant_id) ->orderByDesc('created_at') ->paginate($perPage); return TransactionResource::collection($transactions); } ``` ### Cursor Pagination (for large datasets) ```php public function index(Request $request): AnonymousResourceCollection { $transactions = Transaction::query() ->forTenant($request->user()->tenant_id) ->orderByDesc('created_at') ->cursorPaginate(15); return TransactionResource::collection($transactions); } ``` --- ## HTTP Status Code Consistency (MANDATORY) | Operation | Status Code | Response | |-----------|-------------|----------| | GET (single) | 200 | Resource | | GET (list) | 200 | Paginated collection | | POST (create) | 201 | Created resource | | PUT/PATCH (update) | 200 | Updated resource | | DELETE | 204 | No content | | Validation error | 422 | Error details | | Not found | 404 | Error message | | Unauthorized | 401 | Error message | | Forbidden | 403 | Error message | ```php // ✅ CORRECT public function store(StoreTransactionRequest $request): JsonResponse { $transaction = $this->service->create( CreateTransactionDTO::fromRequest($request) ); return TransactionResource::make($transaction) ->response() ->setStatusCode(201); // 201 for creation } public function destroy(Transaction $transaction): JsonResponse { $this->authorize('delete', $transaction); $transaction->delete(); return response()->json(null, 204); // 204 for deletion } ``` --- ## OpenAPI Documentation (MANDATORY) Use **L5-Swagger** or **Scramble** for auto-documentation. ```php // With Scramble (recommended for Laravel 11+) // composer require dedoc/scramble // config/scramble.php return [ 'api_path' => 'api', 'api_domain' => null, ]; // Or with L5-Swagger annotations /** * @OA\Post( * path="/api/transactions", * summary="Create a transaction", * tags={"Transactions"}, * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/CreateTransaction")), * @OA\Response(response=201, description="Created", @OA\JsonContent(ref="#/components/schemas/Transaction")), * @OA\Response(response=422, description="Validation error"), * security={{"sanctum": {}}} * ) */ ``` --- ## Controller Constructor Pattern (MANDATORY) ```php // ✅ CORRECT - DI via constructor class TransactionController extends Controller { public function __construct( private readonly TransactionService $service, private readonly TracerInterface $tracer, ) {} public function store(StoreTransactionRequest $request): JsonResponse { $span = $this->tracer->spanBuilder('app.controller.store_transaction') ->startSpan(); $scope = $span->activate(); try { $dto = CreateTransactionDTO::fromRequest($request); $transaction = $this->service->create($dto); $span->setStatus(StatusCode::STATUS_OK); return TransactionResource::make($transaction) ->response() ->setStatusCode(201); } catch (\Throwable $e) { $span->recordException($e); $span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage()); throw $e; } finally { $scope->detach(); $span->end(); } } } ``` --- ## Input Validation (MANDATORY) ```php // ✅ CORRECT - Form Request with typed rules class StoreTransactionRequest extends FormRequest { public function authorize(): bool { return $this->user()->can('create', Transaction::class); } public function rules(): array { return [ 'user_id' => ['required', 'uuid', 'exists:users,id'], 'amount' => ['required', 'numeric', 'min:0.01', 'max:999999.99'], 'currency' => ['required', 'string', 'size:3', Rule::in(['BRL', 'USD', 'EUR'])], 'description' => ['nullable', 'string', 'max:255'], 'metadata' => ['nullable', 'array'], 'metadata.*' => ['string', 'max:255'], ]; } public function messages(): array { return [ 'amount.min' => 'Transaction amount must be at least 0.01', 'currency.in' => 'Currency must be BRL, USD, or EUR', ]; } } ``` **Detection:** ```bash find app/Http/Requests/ -name "*.php" | wc -l grep -rn "extends FormRequest" app/ --include="*.php" ``` --- ## Testing ### Pest + PHPUnit Patterns ```php // tests/Unit/Services/TransactionServiceTest.php use App\Services\TransactionService; describe('TransactionService', function () { beforeEach(function () { $this->repository = Mockery::mock(TransactionRepositoryInterface::class); $this->tracer = Mockery::mock(TracerInterface::class); $this->service = new TransactionService($this->repository, $this->tracer); }); it('creates a transaction successfully', function () { $dto = new CreateTransactionDTO( userId: 'user-123', amount: 100.50, currency: 'BRL', ); $this->repository ->shouldReceive('create') ->once() ->andReturn(Transaction::factory()->make(['id' => 'txn-1'])); $this->tracer->shouldReceive('spanBuilder')->andReturnSelf(); $this->tracer->shouldReceive('startSpan')->andReturn(Mockery::mock(SpanInterface::class)); $result = $this->service->create($dto); expect($result)->toBeInstanceOf(Transaction::class) ->and($result->id)->toBe('txn-1'); }); it('throws exception for negative amount', function () { $dto = new CreateTransactionDTO( userId: 'user-123', amount: -10, currency: 'BRL', ); $this->service->create($dto); })->throws(InvalidAmountException::class); }); // Data providers with Pest datasets dataset('currencies', ['BRL', 'USD', 'EUR']); it('accepts valid currencies', function (string $currency) { $dto = new CreateTransactionDTO('user-1', 100, $currency); expect($dto->currency)->toBe($currency); })->with('currencies'); ``` ### Feature Tests ```php // tests/Feature/TransactionApiTest.php use function Pest\Laravel\{postJson, getJson, actingAs}; describe('Transaction API', function () { beforeEach(function () { $this->user = User::factory()->create(); }); it('creates a transaction', function () { actingAs($this->user, 'sanctum') ->postJson('/api/transactions', [ 'user_id' => $this->user->id, 'amount' => 150.00, 'currency' => 'BRL', ]) ->assertStatus(201) ->assertJsonStructure([ 'data' => ['id', 'userId', 'amount', 'currency', 'createdAt'], ]); $this->assertDatabaseHas('transactions', [ 'user_id' => $this->user->id, 'amount' => 150.00, ]); }); it('validates required fields', function () { actingAs($this->user, 'sanctum') ->postJson('/api/transactions', []) ->assertStatus(422) ->assertJsonValidationErrors(['user_id', 'amount', 'currency']); }); }); ``` ### Coverage ```bash # Run with coverage php artisan test --coverage --min=85 # Or with Pest directly ./vendor/bin/pest --coverage --min=85 # HTML report ./vendor/bin/pest --coverage-html=coverage ``` **⛔ HARD GATE:** Minimum 85% code coverage. --- ## Logging ### Structured JSON Logging (MANDATORY) ```php // config/logging.php 'channels' => [ 'structured' => [ 'driver' => 'monolog', 'handler' => StreamHandler::class, 'with' => [ 'stream' => 'php://stdout', ], 'formatter' => JsonFormatter::class, 'processor_handler' => true, ], ], // Usage Log::channel('structured')->info('Transaction created', [ 'transaction_id' => $transaction->id, 'user_id' => $transaction->user_id, 'amount' => $transaction->amount, 'trace_id' => $span->getContext()->getTraceId(), ]); ``` - `echo` for logging - `var_dump()` anywhere - `print_r()` for debugging - `dd()` in committed code - `dump()` in committed code - `error_log()` without structured format **Detection:** ```bash grep -rn "echo \|var_dump\|print_r\|dd(\|dump(" app/ --include="*.php" ``` --- ## Linting ### PHP CS Fixer Configuration ```php // .php-cs-fixer.dist.php in([ __DIR__ . '/app', __DIR__ . '/config', __DIR__ . '/database', __DIR__ . '/routes', __DIR__ . '/tests', ]) ->name('*.php'); return (new PhpCsFixer\Config()) ->setRules([ '@PSR12' => true, 'strict_types' => true, 'declare_strict_types' => true, 'array_syntax' => ['syntax' => 'short'], 'no_unused_imports' => true, 'ordered_imports' => ['sort_algorithm' => 'alpha'], 'single_quote' => true, 'trailing_comma_in_multiline' => true, 'blank_line_before_statement' => ['statements' => ['return']], ]) ->setFinder($finder) ->setRiskyAllowed(true); ``` ### PHPStan Configuration ```neon # phpstan.neon includes: - vendor/larastan/larastan/extension.neon parameters: level: 8 paths: - app excludePaths: - app/Console/Kernel.php reportUnmatchedIgnoredErrors: false ``` **Detection:** ```bash test -f .php-cs-fixer.dist.php && echo "OK" || echo "MISSING .php-cs-fixer.dist.php" test -f phpstan.neon && echo "OK" || echo "MISSING phpstan.neon" grep "level:" phpstan.neon ``` --- ## Production Config Validation (MANDATORY) ```bash # MANDATORY before deployment php artisan config:cache php artisan route:cache php artisan view:cache php artisan event:cache # Validate no env() calls outside config php artisan config:clear grep -rn "env(" app/ --include="*.php" | grep -v "config/" && echo "FAIL: env() found outside config" || echo "OK" ``` --- ## Container Security (CONDITIONAL) **⚠️ CONDITIONAL:** Only if Dockerfile exists in project. ```dockerfile # Multi-stage build for PHP/Laravel FROM composer:2 AS deps WORKDIR /app COPY composer.json composer.lock ./ RUN composer install --no-dev --no-scripts --prefer-dist FROM php:8.3-fpm-alpine AS production # Install extensions RUN docker-php-ext-install pdo pdo_pgsql opcache # Security: non-root user RUN addgroup -g 1000 app && adduser -u 1000 -G app -s /bin/sh -D app WORKDIR /app COPY --from=deps /app/vendor ./vendor COPY . . RUN php artisan config:cache && \ php artisan route:cache && \ php artisan view:cache USER app EXPOSE 9000 CMD ["php-fpm"] ``` **Detection:** ```bash grep -n "USER\|nonroot\|adduser" Dockerfile grep -n "FROM.*alpine\|FROM.*slim" Dockerfile ``` --- ## Architecture Patterns ### Hexagonal/Lerian Pattern (MANDATORY) ``` app/ ├── Application/ # Use cases, commands, queries │ ├── Command/ # Write operations │ ├── Query/ # Read operations │ ├── DTO/ # Data Transfer Objects │ └── Service/ # Application services (orchestration) ├── Domain/ # Business logic (framework-agnostic) │ ├── Entity/ # Domain entities (Always-Valid) │ ├── ValueObject/ # Value objects (immutable) │ ├── Repository/ # Repository interfaces (ports) │ ├── Event/ # Domain events │ ├── Exception/ # Domain exceptions │ └── Service/ # Domain services (pure logic) ├── Infrastructure/ # External adapters │ ├── Persistence/ # Database implementations │ │ └── Eloquent/ # Eloquent repositories │ ├── Messaging/ # Queue implementations │ ├── Http/ # HTTP client adapters │ ├── Cache/ # Cache implementations │ └── Observability/ # Tracing, logging config └── Presentation/ # UI layer ├── Http/ │ ├── Controller/ │ ├── Middleware/ │ ├── Request/ # Form Requests │ └── Resource/ # API Resources └── Console/ # Artisan commands ``` --- ## Directory Structure Standard Laravel with hexagonal overlay: ``` project/ ├── app/ │ ├── Application/ # Use cases │ ├── Domain/ # Business logic │ ├── Infrastructure/ # Adapters │ ├── Presentation/ # Controllers, Resources │ ├── Models/ # Eloquent models (Laravel convention) │ ├── Providers/ # Service Providers │ └── Logging/ # Custom log processors ├── bootstrap/ │ ├── app.php │ └── providers.php ├── config/ ├── database/ │ ├── factories/ │ ├── migrations/ │ └── seeders/ ├── routes/ │ ├── api.php │ └── web.php ├── tests/ │ ├── Unit/ │ ├── Feature/ │ ├── Integration/ │ └── Chaos/ ├── composer.json ├── phpstan.neon ├── .php-cs-fixer.dist.php └── Makefile ``` --- ## N+1 Query Detection (MANDATORY) ```php // AppServiceProvider.php - MANDATORY public function boot(): void { // Prevent lazy loading in non-production Model::preventLazyLoading(!app()->isProduction()); // Log lazy loading violations in production Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) { Log::warning('Lazy loading detected', [ 'model' => get_class($model), 'relation' => $relation, ]); }); } // ✅ CORRECT - Eager loading $transactions = Transaction::with(['user', 'items'])->paginate(15); // ❌ WRONG - N+1 query $transactions = Transaction::all(); foreach ($transactions as $transaction) { echo $transaction->user->name; // N+1! } ``` **Detection:** ```bash grep -rn "preventLazyLoading\|handleLazyLoadingViolation" app/Providers/ --include="*.php" grep -rn "::with\[" app/ --include="*.php" ``` --- ## Performance Patterns (MANDATORY) ### Chunking for Large Datasets ```php // ✅ Process large datasets without memory issues Transaction::where('status', 'pending') ->chunkById(1000, function (Collection $transactions) { foreach ($transactions as $transaction) { $this->process($transaction); } }); // ✅ Lazy collections for streaming Transaction::where('status', 'pending') ->lazy() ->each(fn (Transaction $txn) => $this->process($txn)); ``` ### Caching ```php // ✅ Cache expensive queries $stats = Cache::remember( "tenant:{$tenantId}:stats", now()->addMinutes(15), fn () => Transaction::forTenant($tenantId) ->selectRaw('COUNT(*) as total, SUM(amount) as sum') ->first() ); ``` ### Queue for Heavy Operations ```php // ✅ Offload heavy operations to queue dispatch(new ProcessTransactionJob($transaction)) ->onQueue('transactions') ->delay(now()->addSeconds(5)); ``` **Detection:** ```bash grep -rn "->chunk\|->chunkById\|->lazy\|Cache::remember" app/ --include="*.php" ``` --- ## RabbitMQ Worker Pattern ```php // Using php-amqplib with Laravel class RabbitMQConsumer { private AMQPStreamConnection $connection; private AMQPChannel $channel; public function __construct( private readonly TransactionService $service, private readonly LoggerInterface $logger, private readonly TracerInterface $tracer, ) {} public function consume(string $queueName): void { $this->connect(); $this->channel->basic_qos(0, 10, false); $this->channel->basic_consume( queue: $queueName, callback: fn (AMQPMessage $msg) => $this->handleMessage($msg), ); while ($this->channel->is_consuming()) { $this->channel->wait(); } } private function handleMessage(AMQPMessage $message): void { $span = $this->tracer->spanBuilder('app.queue.process_message') ->startSpan(); $scope = $span->activate(); try { $data = json_decode($message->getBody(), true, 512, JSON_THROW_ON_ERROR); $this->service->process($data); $message->ack(); $span->setStatus(StatusCode::STATUS_OK); } catch (\Throwable $e) { $this->logger->error('Message processing failed', [ 'error' => $e->getMessage(), 'queue' => $message->getRoutingKey(), ]); $span->recordException($e); $span->setStatus(StatusCode::STATUS_ERROR); $message->nack(requeue: true); } finally { $scope->detach(); $span->end(); } } private function connect(): void { $this->connection = new AMQPStreamConnection( config('rabbitmq.host'), config('rabbitmq.port'), config('rabbitmq.user'), config('rabbitmq.password'), config('rabbitmq.vhost'), ); $this->channel = $this->connection->channel(); } } ``` --- ## RabbitMQ Reconnection Strategy (MANDATORY) ```php class ResilientConsumer { private const MAX_RECONNECT_ATTEMPTS = 10; private const BASE_DELAY_MS = 1000; private const MAX_DELAY_MS = 30000; public function consumeWithReconnection(string $queue): void { $attempt = 0; while (true) { try { $this->connect(); $this->consume($queue); $attempt = 0; // Reset on successful consumption } catch (AMQPConnectionClosedException | AMQPIOException $e) { $attempt++; if ($attempt > self::MAX_RECONNECT_ATTEMPTS) { $this->logger->critical('Max reconnection attempts reached', [ 'queue' => $queue, 'attempts' => $attempt, ]); throw $e; } $delay = $this->calculateBackoff($attempt); $this->logger->warning('RabbitMQ connection lost, reconnecting', [ 'attempt' => $attempt, 'delay_ms' => $delay, ]); usleep($delay * 1000); } } } private function calculateBackoff(int $attempt): int { $delay = min( self::BASE_DELAY_MS * (2 ** ($attempt - 1)), self::MAX_DELAY_MS ); // Add jitter (±25%) $jitter = $delay * 0.25; return (int) ($delay + random_int((int) -$jitter, (int) $jitter)); } } ``` --- ## Always-Valid Domain Model (MANDATORY) ```php // ✅ Constructor validation - entity is ALWAYS valid final class Money { private function __construct( public readonly float $amount, public readonly string $currency, ) {} public static function create(float $amount, string $currency): self { if ($amount < 0) { throw new InvalidArgumentException('Amount cannot be negative'); } if (!in_array($currency, ['BRL', 'USD', 'EUR'], true)) { throw new InvalidArgumentException("Invalid currency: {$currency}"); } return new self($amount, $currency); } public function add(Money $other): self { if ($this->currency !== $other->currency) { throw new CurrencyMismatchException($this->currency, $other->currency); } return new self($this->amount + $other->amount, $this->currency); } } // ✅ Domain entity with invariant protection final class Transaction { private function __construct( public readonly string $id, public readonly string $tenantId, public readonly string $userId, public readonly Money $amount, private TransactionStatus $status, ) {} public static function create( string $tenantId, string $userId, Money $amount, ): self { if ($amount->amount <= 0) { throw new InvalidAmountException('Transaction amount must be positive'); } return new self( id: (string) Str::uuid(), tenantId: $tenantId, userId: $userId, amount: $amount, status: TransactionStatus::PENDING, ); } public function approve(): void { if ($this->status !== TransactionStatus::PENDING) { throw new InvalidTransitionException("Cannot approve {$this->status->value} transaction"); } $this->status = TransactionStatus::APPROVED; } } ``` --- ## Idempotency Patterns (MANDATORY for Transaction APIs) ```php // Middleware for idempotency class IdempotencyMiddleware { public function handle(Request $request, Closure $next): Response { if (!in_array($request->method(), ['POST', 'PUT', 'PATCH'])) { return $next($request); } $key = $request->header('Idempotency-Key'); if (!$key) { return $next($request); } $cacheKey = "idempotency:{$key}"; // Check for existing response $cached = Cache::get($cacheKey); if ($cached) { return response()->json( $cached['body'], $cached['status'], ['X-Idempotent-Replayed' => 'true'] ); } // Acquire lock to prevent concurrent execution $lock = Cache::lock("lock:{$cacheKey}", 30); if (!$lock->get()) { return response()->json( ['error' => 'Concurrent request with same idempotency key'], 409 ); } try { $response = $next($request); // Cache successful responses for 24 hours if ($response->isSuccessful()) { Cache::put($cacheKey, [ 'body' => json_decode($response->getContent(), true), 'status' => $response->getStatusCode(), ], now()->addHours(24)); } return $response; } finally { $lock->release(); } } } ``` --- ## Multi-Tenant Patterns (CONDITIONAL) **⚠️ CONDITIONAL:** Only when multi-tenancy is required. ```php // Using stancl/tenancy or custom implementation // Tenant middleware class TenantMiddleware { public function handle(Request $request, Closure $next): Response { $tenantId = $request->header('X-Tenant-ID') ?? $request->user()?->tenant_id; if (!$tenantId) { return response()->json(['error' => 'Tenant not identified'], 403); } app()->instance('tenant_id', $tenantId); // Set global scope on all tenant models TenantScope::setTenantId($tenantId); return $next($request); } } // Global scope for tenant isolation class TenantScope implements Scope { private static ?string $tenantId = null; public static function setTenantId(string $id): void { self::$tenantId = $id; } public function apply(Builder $builder, Model $model): void { if (self::$tenantId) { $builder->where('tenant_id', self::$tenantId); } } } // Trait for tenant models trait BelongsToTenant { protected static function booted(): void { static::addGlobalScope(new TenantScope()); static::creating(function (Model $model) { if (!$model->tenant_id) { $model->tenant_id = app('tenant_id'); } }); } } ``` --- ## Rate Limiting (MANDATORY) ```php // app/Providers/AppServiceProvider.php public function boot(): void { RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60) ->by($request->user()?->id ?: $request->ip()) ->response(function (Request $request, array $headers) { return response()->json([ 'error' => 'Too many requests', 'retry_after' => $headers['Retry-After'], ], 429, $headers); }); }); RateLimiter::for('auth', function (Request $request) { return Limit::perMinute(5)->by($request->ip()); }); } // routes/api.php Route::middleware(['throttle:api'])->group(function () { Route::apiResource('transactions', TransactionController::class); }); Route::middleware(['throttle:auth'])->group(function () { Route::post('/login', [AuthController::class, 'login']); }); ``` --- ## CORS Configuration (MANDATORY) ```php // config/cors.php return [ 'paths' => ['api/*'], 'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], 'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', '')), 'allowed_origins_patterns' => [], 'allowed_headers' => [ 'Content-Type', 'Authorization', 'X-Requested-With', 'X-Tenant-ID', 'Idempotency-Key', 'Accept', ], 'exposed_headers' => ['X-Idempotent-Replayed', 'Retry-After'], 'max_age' => 3600, 'supports_credentials' => true, ]; ``` **⛔ PRODUCTION VALIDATION:** - `allowed_origins` MUST NOT contain `*` in production - `allowed_origins` MUST NOT be empty in production **Detection:** ```bash grep -n "allowed_origins" config/cors.php ``` --- ## Checklist Self-verification before marking implementation complete: ```text 1. [ ] declare(strict_types=1) in all files? 2. [ ] No env() calls outside config/ files? 3. [ ] No die()/exit()/dd()/dump() in committed code? 4. [ ] No echo/var_dump/print_r for logging? 5. [ ] All models have $fillable or $guarded? 6. [ ] preventLazyLoading() enabled? 7. [ ] Form Requests for all input validation? 8. [ ] API Resources for all JSON output? 9. [ ] camelCase in JSON responses? 10. [ ] 201 for POST creation, 204 for DELETE? 11. [ ] Health check endpoints (/health, /ready)? 12. [ ] OpenTelemetry spans on service methods? 13. [ ] Structured JSON logging (no echo)? 14. [ ] PHPStan Level 8+ passes? 15. [ ] PHP CS Fixer passes? 16. [ ] Pest tests with 85%+ coverage? 17. [ ] composer.lock committed? 18. [ ] roave/security-advisories installed? 19. [ ] Dockerfile runs as non-root? 20. [ ] Exception hierarchy (no generic exceptions)? ```