Tech Stack: PHP 8.2+, Laravel 10+, MySQL 8+
This document defines mandatory standards and best practices for building scalable, maintainable, secure, and high‑performance applications using PHP, Laravel, and MySQL. All developers must follow these guidelines consistently.
- SOLID principles must be followed
- YAGNI (You Aren't Gonna Need It) - Don't add functionality until it's necessary
- Separation of Concerns (SoC)
- Single Responsibility for classes and methods
- Fail fast, validate early
- Explicit over implicit
- Readable code > clever code
- KISS (Keep It Simple, Stupid) - Prefer simple solutions over complex ones
-
Code must be:
- Readable
- Testable
- Extensible
- Documented
-
No business logic in controllers or views
-
No direct DB queries outside repositories
- Minimum PHP 8.2
- Use strict typing
declare(strict_types=1);- PSR-1: Basic Coding Standard
- PSR-3: Logger Interface (use Monolog)
- PSR-4: Autoloading
- PSR-7: HTTP Message Interfaces (when working with HTTP libraries)
- PSR-11: Container Interface (Laravel's container implements this)
- PSR-12: Extended Coding Style
-
Always use:
- Scalar type hints (int, string, bool, float, array)
- Return types (including
voidfor methods that return nothing) - Property type hints (PHP 7.4+)
- Union types (PHP 8.0+) when appropriate
- Nullable types (
?TypeorType|null)
declare(strict_types=1);
class UserService
{
public function __construct(
private UserRepositoryInterface $repository
) {}
public function handle(User $user): bool
{
return $user->isActive();
}
public function findById(int $id): ?User
{
return $this->repository->findById($id);
}
public function process(string|int $identifier): void
{
// Process identifier
}
}-
Use PHP 8.2+ features:
- Readonly properties for immutable DTOs
- Enums for fixed sets of values
- Match expressions instead of switch when appropriate
- Named arguments for clarity
- Attributes (PHP 8.0+) instead of annotations where applicable
enum UserStatus: string
{
case ACTIVE = 'active';
case INACTIVE = 'inactive';
case SUSPENDED = 'suspended';
}
class UserDTO
{
public function __construct(
public readonly string $email,
public readonly UserStatus $status
) {}
}- Level 8 or max
- No ignored errors without justification
- Use PHPStan extensions for Laravel when available
- Run PHPStan in CI pipeline
app/
├── Actions/ # Single-purpose business operations
│ └── User/
│ ├── CreateUserAction.php
│ └── UpdateUserAction.php
├── Console/
│ └── Commands/ # Artisan commands
├── DTOs/ # Data Transfer Objects
│ └── UserDTO.php
├── Events/ # Domain events
│ └── UserRegistered.php
├── Exceptions/ # Custom exceptions
│ └── UserAlreadyExistsException.php
├── Http/
│ ├── Controllers/
│ │ ├── Api/ # API controllers
│ │ │ └── V1/
│ │ └── Web/ # Web controllers (if needed)
│ ├── Middleware/
│ ├── Requests/ # Form Requests
│ │ └── User/
│ │ ├── StoreUserRequest.php
│ │ └── UpdateUserRequest.php
│ └── Resources/ # API Resources
│ └── UserResource.php
├── Jobs/ # Queue jobs
│ └── SendWelcomeEmail.php
├── Listeners/ # Event listeners
│ └── SendWelcomeEmailListener.php
├── Models/
│ ├── User.php
│ └── Traits/ # Model traits
├── Observers/ # Model observers
│ └── UserObserver.php
├── Policies/ # Authorization policies
│ └── UserPolicy.php
├── Providers/
│ ├── AppServiceProvider.php
│ └── RepositoryServiceProvider.php
├── Repositories/
│ ├── Contracts/ # Repository interfaces
│ │ └── UserRepositoryInterface.php
│ └── Eloquent/ # Repository implementations
│ └── EloquentUserRepository.php
├── Services/ # Business logic services
│ └── UserService.php
└── Support/ # Helper classes, utilities
└── Helpers/
config/ # Configuration files
database/
├── factories/ # Model factories
├── migrations/ # Database migrations
├── seeders/ # Database seeders
└── testing/ # Test database setup
routes/
├── api.php # API routes
├── web.php # Web routes
└── channels.php # Broadcasting channels
resources/
├── lang/ # Language files
└── views/ # Blade templates (if using)
tests/
├── Feature/ # Feature tests
├── Unit/ # Unit tests
└── Pest.php # Pest configuration
- Group by feature when appropriate (for large applications)
- Use subdirectories for related classes (e.g.,
User/for all user-related classes) - Keep flat structure for small to medium applications
- Consistent naming across directories
- Separate API and Web controllers if both exist
- Version API controllers (
Api/V1/,Api/V2/)
-
Controllers must be thin
-
No business logic
-
Use Resource Controllers for CRUD operations
-
Only:
- Request validation (via Form Requests)
- Authorization (via Policies/Gates)
- Delegation to services/actions
- Response formatting
class UserController extends Controller
{
public function __construct(
private CreateUserAction $createAction,
private UpdateUserAction $updateAction
) {}
public function store(StoreUserRequest $request): JsonResponse
{
$user = $this->createAction->execute($request->dto());
return response()->json(
new UserResource($user),
201
);
}
public function update(UpdateUserRequest $request, User $user): JsonResponse
{
$this->authorize('update', $user);
$updated = $this->updateAction->execute($user, $request->dto());
return response()->json(new UserResource($updated));
}
}- Use API Resources for response transformation
- Separate resources for different contexts (index vs show)
- Always use resources for API responses
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'email' => $this->email,
'name' => $this->name,
'created_at' => $this->created_at?->toIso8601String(),
];
}
}
// For collections
class UserCollection extends ResourceCollection
{
public $collects = UserResource::class;
}- Use Events for decoupled business logic
- Keep listeners focused and testable
- Use queued listeners for heavy operations
// Event
class UserRegistered
{
public function __construct(
public User $user
) {}
}
// Listener
class SendWelcomeEmail
{
public function handle(UserRegistered $event): void
{
// Send email
}
}- Use Jobs for asynchronous operations
- Always implement
ShouldQueuefor long-running tasks - Use
ShouldBeUniquefor idempotent operations - Handle failures gracefully
class ProcessPayment implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private Order $order
) {}
public function handle(PaymentService $service): void
{
$service->process($this->order);
}
public function failed(Throwable $exception): void
{
// Handle failure
}
}- Use Observers for model lifecycle hooks
- Keep observers focused on single responsibility
- Avoid business logic in observers
class UserObserver
{
public function created(User $user): void
{
// Log creation
}
public function updating(User $user): void
{
// Track changes
}
}- Always use constructor injection for dependencies
- Bind interfaces to implementations in service providers
- Use contextual binding when needed
- Avoid service locator pattern (
app()->make())
// Good: Constructor injection
class UserService
{
public function __construct(
private UserRepositoryInterface $repository,
private EmailService $emailService
) {}
}
// Service Provider binding
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(
UserRepositoryInterface::class,
EloquentUserRepository::class
);
// Singleton binding
$this->app->singleton(
PaymentGatewayInterface::class,
StripePaymentGateway::class
);
// Contextual binding
$this->app->when(OrderController::class)
->needs(PaymentGatewayInterface::class)
->give(StripePaymentGateway::class);
}
}- Use Collections for array manipulation
- Chain methods for readability
- Use higher-order messages for cleaner code
- Lazy collections for large datasets
// Good: Collection methods
$activeUsers = User::all()
->filter(fn ($user) => $user->isActive())
->map(fn ($user) => $user->name)
->values();
// Higher-order messages
$users->each->sendEmail();
$users->map->toArray();
// Lazy collections for large datasets
User::cursor()
->filter(fn ($user) => $user->isActive())
->each(fn ($user) => $user->sendEmail());- Define relationships in models
- Use relationship methods instead of manual joins when possible
- Eager load relationships to avoid N+1
- Use
has()andwhereHas()for relationship queries
// Model relationships
class User extends Model
{
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
public function activeOrders(): HasMany
{
return $this->orders()->where('status', 'active');
}
}
// Usage
$users = User::with('orders')->get(); // Eager load
$usersWithOrders = User::has('orders')->get(); // Has relationship
$usersWithActiveOrders = User::whereHas('orders', function ($query) {
$query->where('status', 'active');
})->get();- Use query scopes for reusable query logic
- Use global scopes for default constraints
- Use local scopes for conditional queries
- Chain scopes for complex queries
class User extends Model
{
// Local scope
public function scopeActive(Builder $query): Builder
{
return $query->where('status', 'active');
}
public function scopeEmailVerified(Builder $query): Builder
{
return $query->whereNotNull('email_verified_at');
}
// Global scope
protected static function booted(): void
{
static::addGlobalScope('active', function (Builder $builder) {
$builder->where('status', '!=', 'deleted');
});
}
}
// Usage
User::active()->emailVerified()->get();- Use accessors for computed attributes
- Use mutators for data transformation
- Use attribute casting for type conversion
- Use
appendsfor computed attributes in JSON
class User extends Model
{
protected $casts = [
'email_verified_at' => 'datetime',
'metadata' => 'array',
'is_admin' => 'boolean',
];
protected $appends = ['full_name'];
// Accessor
public function getFullNameAttribute(): string
{
return "{$this->first_name} {$this->last_name}";
}
// Mutator
public function setEmailAttribute(string $value): void
{
$this->attributes['email'] = strtolower($value);
}
}- Use route model binding for cleaner routes
- Customize binding with
getRouteKeyName() - Use scoped binding for nested resources
- Handle missing models with custom resolution
// Route
Route::get('/users/{user}', [UserController::class, 'show']);
// Controller
public function show(User $user): JsonResponse
{
return response()->json(new UserResource($user));
}
// Custom route key
class User extends Model
{
public function getRouteKeyName(): string
{
return 'slug'; // Instead of 'id'
}
}
// Scoped binding
Route::get('/users/{user}/orders/{order}', function (User $user, Order $order) {
// $order is automatically scoped to $user
})->scopeBindings();- Create focused middleware (single responsibility)
- Use middleware groups for organization
- Apply middleware at route, controller, or global level
- Use middleware parameters when needed
// Custom middleware
class EnsureUserIsActive
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->user()->isActive()) {
abort(403, 'User account is inactive');
}
return $next($request);
}
}
// Middleware with parameters
class EnsureUserHasRole
{
public function handle(Request $request, Closure $next, string $role): Response
{
if (!$request->user()->hasRole($role)) {
abort(403);
}
return $next($request);
}
}
// Usage
Route::middleware(['auth', 'active', 'role:admin'])->group(function () {
// Routes
});- Cache expensive operations
- Use cache tags (Redis) for grouped invalidation
- Cache queries with
remember() - Use cache locks for preventing cache stampede
// Query caching
$users = Cache::remember('users.active', 3600, function () {
return User::where('status', 'active')->get();
});
// Cache tags (Redis)
Cache::tags(['users', 'active'])
->remember('users.active', 3600, fn () => User::active()->get());
Cache::tags(['users'])->flush(); // Invalidate all user caches
// Cache locks (prevent stampede)
$lock = Cache::lock('process-payment', 10);
if ($lock->get()) {
try {
// Process payment
} finally {
$lock->release();
}
}- Use queues for long-running tasks
- Implement retry logic with exponential backoff
- Use job batching for related operations
- Handle failures gracefully
- Use job middleware for cross-cutting concerns
class ProcessPayment implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $backoff = [60, 300, 900]; // Exponential backoff
public function __construct(
private Order $order
) {}
public function handle(PaymentService $service): void
{
$service->process($this->order);
}
public function failed(Throwable $exception): void
{
// Handle failure
Log::error('Payment processing failed', [
'order_id' => $this->order->id,
'error' => $exception->getMessage(),
]);
}
}
// Job batching
Bus::batch([
new ProcessOrder($order1),
new ProcessOrder($order2),
new ProcessOrder($order3),
])->then(function (Batch $batch) {
// All jobs completed
})->catch(function (Batch $batch, Throwable $e) {
// First batch job failure
})->finally(function (Batch $batch) {
// Batch finished
})->dispatch();- Use events for decoupled architecture
- Queue listeners for heavy operations
- Use event subscribers for multiple related listeners
- Dispatch events after important actions
// Event
class OrderShipped
{
public function __construct(
public Order $order
) {}
}
// Listener (queued)
class SendShipmentNotification implements ShouldQueue
{
public function handle(OrderShipped $event): void
{
Mail::to($event->order->user)->send(new OrderShippedMail($event->order));
}
}
// Event subscriber
class OrderEventSubscriber
{
public function handleOrderShipped(OrderShipped $event): void
{
// Handle shipped
}
public function handleOrderCancelled(OrderCancelled $event): void
{
// Handle cancelled
}
public function subscribe(Dispatcher $events): array
{
return [
OrderShipped::class => 'handleOrderShipped',
OrderCancelled::class => 'handleOrderCancelled',
];
}
}- Create custom rules for reusable validation
- Use rule objects for complex validation
- Validate files properly (size, mime types)
- Use conditional validation with
sometimes()
// Custom rule
class StrongPassword implements Rule
{
public function passes($attribute, $value): bool
{
return preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $value);
}
public function message(): string
{
return 'The :attribute must be at least 8 characters with uppercase, lowercase, and numbers.';
}
}
// Usage
$request->validate([
'password' => ['required', new StrongPassword()],
]);
// Conditional validation
$request->validate([
'email' => 'required|email',
'phone' => 'sometimes|required|phone', // Only if present
]);- Implement rate limiting for API endpoints
- Use different limits for different user types
- Customize rate limiters for specific needs
- Return proper headers with rate limit info
// In RouteServiceProvider
protected function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('strict', function (Request $request) {
return Limit::perMinute(10)->by($request->ip());
});
}
// Usage
Route::middleware(['throttle:api'])->group(function () {
// Routes
});
Route::middleware(['throttle:strict'])->post('/login', ...);- Use storage facade for file operations
- Use disk drivers appropriately (local, s3, etc.)
- Generate unique filenames to prevent conflicts
- Validate file uploads properly
// Store file
$path = $request->file('avatar')->store('avatars', 's3');
// Store with custom name
$path = Storage::disk('s3')->putFileAs(
'avatars',
$request->file('avatar'),
$user->id . '.jpg'
);
// Generate temporary URL
$url = Storage::disk('s3')->temporaryUrl(
$path,
now()->addMinutes(5)
);- Use structured logging with context
- Use appropriate log levels
- Never log sensitive data
- Use Laravel Telescope in development (never in production)
// Structured logging
Log::info('User registered', [
'user_id' => $user->id,
'email' => $user->email,
'ip' => $request->ip(),
]);
// Log with context
Log::channel('slack')->error('Payment failed', [
'order_id' => $order->id,
'amount' => $order->total,
'user_id' => $order->user_id,
]);- Use eager loading to prevent N+1 queries
- Select specific columns when possible
- Use database indexes effectively
- Cache expensive operations
- Use lazy loading for large collections
- Optimize autoloader in production
// Eager load relationships
User::with(['orders', 'profile'])->get();
// Select specific columns
User::select('id', 'name', 'email')->get();
// Lazy loading for large datasets
User::cursor()->each(function ($user) {
// Process user
});
// Optimize autoloader (run in production)
// composer dump-autoload -o- Cache configuration:
php artisan config:cache - Cache routes:
php artisan route:cache - Cache views:
php artisan view:cache - Cache events:
php artisan event:cache - Optimize autoloader:
composer dump-autoload -o - Disable debug mode:
APP_DEBUG=false - Use queue workers: Process jobs asynchronously
- Use OPcache: Enable PHP OPcache
- Use Redis: For cache and sessions
- Enable HTTP/2: For faster page loads
- Use CDN: For static assets
- Monitor performance: Use APM tools (Laravel Telescope in dev only)
# Production deployment script
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
composer dump-autoload -o- Create custom commands for repetitive tasks
- Use command signatures for better organization
- Add command descriptions and help text
- Use command options and arguments appropriately
- Test commands with Pest
class SendWeeklyReport extends Command
{
protected $signature = 'reports:weekly
{--email= : Email to send report to}
{--force : Force send even if already sent}';
protected $description = 'Send weekly report to administrators';
public function handle(ReportService $service): int
{
$email = $this->option('email') ?? config('reports.default_email');
$force = $this->option('force');
$this->info('Generating weekly report...');
$service->sendWeeklyReport($email, $force);
$this->info('Weekly report sent successfully!');
return self::SUCCESS;
}
}- Use factories for test data
- Use database transactions in tests
- Use HTTP testing helpers for API tests
- Use model factories for relationships
- Use test traits for common setup
// Feature test with factories
it('creates a user with orders', function () {
$user = User::factory()
->has(Order::factory()->count(3))
->create();
expect($user->orders)->toHaveCount(3);
});
// HTTP test
it('returns user data', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->getJson("/api/v1/users/{$user->id}");
$response->assertOk()
->assertJsonStructure([
'data' => ['id', 'name', 'email'],
]);
});- Customize exception handler for consistent error responses
- Use exception reporting for error tracking
- Log exceptions with context
- Use Sentry/Bugsnag for production error tracking
// In App\Exceptions\Handler
public function register(): void
{
$this->reportable(function (Throwable $e) {
if (app()->bound('sentry')) {
app('sentry')->captureException($e);
}
});
$this->renderable(function (ValidationException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json([
'message' => 'Validation failed',
'errors' => $e->errors(),
], 422);
}
});
}- Use HTTPS in production
- Set secure cookies:
SESSION_SECURE_COOKIE=true - Use CSRF protection: Enabled by default
- Sanitize user input: Always validate and sanitize
- Use prepared statements: Eloquent does this automatically
- Hash passwords: Use
Hash::make() - Rate limit APIs: Prevent abuse
- Use environment variables: Never hardcode secrets
- Keep dependencies updated:
composer audit
// Secure session configuration
// config/session.php
'secure' => env('SESSION_SECURE_COOKIE', true),
'http_only' => true,
'same_site' => 'strict',- Always use Form Requests
- No validation in controllers
- Use Rule class for complex validation
- Customize error messages
- Implement authorization in Form Requests when applicable
class StoreUserRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', User::class);
}
public function rules(): array
{
return [
'email' => [
'required',
'email:rfc,dns',
Rule::unique('users')->whereNull('deleted_at'),
],
'password' => [
'required',
'string',
'min:8',
'regex:/[A-Z]/',
'regex:/[a-z]/',
'regex:/[0-9]/',
],
'name' => ['required', 'string', 'max:255'],
'role' => ['required', Rule::in(['user', 'admin'])],
];
}
public function messages(): array
{
return [
'email.unique' => 'This email is already registered.',
'password.regex' => 'Password must contain uppercase, lowercase, and numbers.',
];
}
public function attributes(): array
{
return [
'email' => 'email address',
];
}
}- Decouples business logic from framework
- Enables testing and scalability
- Note: See Section 7.1 for guidance on when to combine with Actions and DTOs
interface UserRepositoryInterface
{
public function create(array $data): User;
public function findById(int $id): ?User;
}class EloquentUserRepository implements UserRepositoryInterface
{
public function create(array $data): User
{
return User::create($data);
}
}- Services contain business logic
- Services depend on repositories, not models directly
- Use dependency injection
- Keep services focused and testable
class UserService
{
public function __construct(
private UserRepositoryInterface $users,
private EmailService $emailService
) {}
public function register(UserDTO $dto): User
{
$user = $this->users->create($dto->toArray());
$this->emailService->sendWelcomeEmail($user);
return $user;
}
public function updateProfile(User $user, ProfileDTO $dto): User
{
return $this->users->update($user, $dto->toArray());
}
}- Use constructor injection
- Leverage Laravel's service container
- Bind interfaces to implementations in service providers
- Prefer interfaces over concrete classes
// In AppServiceProvider
public function register(): void
{
$this->app->bind(
UserRepositoryInterface::class,
EloquentUserRepository::class
);
}- Each business operation = one Action
- Actions are single-purpose, focused operations
- Actions orchestrate services and handle workflow
- Use actions for complex operations that span multiple services
- Actions make controllers even thinner
class CreateUserAction
{
public function __construct(
private UserService $userService,
private EmailService $emailService,
private EventDispatcher $events
) {}
public function execute(UserDTO $dto): User
{
$user = $this->userService->register($dto);
$this->emailService->sendWelcomeEmail($user);
$this->events->dispatch(new UserRegistered($user));
return $user;
}
}- Complex operations involving multiple services
- Operations that need orchestration (workflow management)
- Operations with side effects across multiple domains
- When you want to make controllers extremely thin
- See Section 7.1 for pattern combination guidelines
- Simple CRUD operations (use services directly)
- Operations that are just a single service method call
- When it adds unnecessary abstraction (follow YAGNI)
- Simple updates that don't require orchestration
- Prevent array abuse
- Strong typing
- Use readonly properties for immutability
- Implement
toArray()for serialization - Use static factory methods for creation
- Recommended for all data transfer (see Section 7.1 for pattern combinations)
class UserDTO
{
public function __construct(
public readonly string $email,
public readonly string $password,
public readonly ?string $name = null,
public readonly UserStatus $status = UserStatus::ACTIVE,
) {}
public static function fromRequest(StoreUserRequest $request): self
{
return new self(
email: $request->validated('email'),
password: $request->validated('password'),
name: $request->validated('name'),
);
}
public function toArray(): array
{
return [
'email' => $this->email,
'password' => $this->password,
'name' => $this->name,
'status' => $this->status->value,
];
}
}Following YAGNI principle, use patterns only when they add value. Here's the recommended approach:
For complex business operations that involve multiple steps, use all patterns together:
Controller → Action → Service → Repository → Model
↓
DTO
Example: User Registration with Email Verification
// 1. Controller (thin)
class UserController extends Controller
{
public function store(
StoreUserRequest $request,
RegisterUserAction $action
): JsonResponse {
$user = $action->execute($request->dto());
return response()->json(
new UserResource($user),
201
);
}
}
// 2. Form Request (validation + DTO creation)
class StoreUserRequest extends FormRequest
{
public function rules(): array { /* ... */ }
public function dto(): UserDTO
{
return UserDTO::fromRequest($this);
}
}
// 3. DTO (type-safe data transfer)
class UserDTO
{
public function __construct(
public readonly string $email,
public readonly string $password,
public readonly string $name,
) {}
public static function fromRequest(StoreUserRequest $request): self
{
return new self(
email: $request->validated('email'),
password: $request->validated('password'),
name: $request->validated('name'),
);
}
}
// 4. Action (orchestrates the workflow)
class RegisterUserAction
{
public function __construct(
private UserService $userService,
private EmailService $emailService,
) {}
public function execute(UserDTO $dto): User
{
$user = $this->userService->register($dto);
$this->emailService->sendVerificationEmail($user);
return $user;
}
}
// 5. Service (business logic)
class UserService
{
public function __construct(
private UserRepositoryInterface $repository,
private HashService $hashService,
) {}
public function register(UserDTO $dto): User
{
return $this->repository->create([
'email' => $dto->email,
'password' => $this->hashService->make($dto->password),
'name' => $dto->name,
]);
}
}
// 6. Repository (data access)
class EloquentUserRepository implements UserRepositoryInterface
{
public function create(array $data): User
{
return User::create($data);
}
}For simple CRUD operations, skip Actions and use Service + Repository:
Controller → Service → Repository → Model
↓
DTO (optional, but recommended)
Example: Simple User Update
// Controller
class UserController extends Controller
{
public function update(
UpdateUserRequest $request,
UserService $service,
User $user
): JsonResponse {
$this->authorize('update', $user);
$updated = $service->update($user, $request->dto());
return response()->json(new UserResource($updated));
}
}
// Service (directly handles the operation)
class UserService
{
public function __construct(
private UserRepositoryInterface $repository
) {}
public function update(User $user, UserDTO $dto): User
{
return $this->repository->update($user, [
'name' => $dto->name,
'email' => $dto->email,
]);
}
}For very simple operations (single model update), you can skip Service layer:
Controller → Repository → Model
↓
DTO (still recommended for type safety)
Example: Toggle User Status
// Controller
class UserController extends Controller
{
public function toggleStatus(
UserRepositoryInterface $repository,
User $user
): JsonResponse {
$this->authorize('update', $user);
$user = $repository->toggleStatus($user);
return response()->json(new UserResource($user));
}
}| Operation Complexity | Action | Service | Repository | DTO |
|---|---|---|---|---|
| Complex (multiple services, orchestration) | ✅ Required | ✅ Required | ✅ Required | ✅ Required |
| Medium (business logic, single service) | ❌ Skip | ✅ Required | ✅ Required | ✅ Recommended |
| Simple (direct data access) | ❌ Skip | ❌ Skip | ✅ Required | ✅ Recommended |
| Very Simple (single field update) | ❌ Skip | ❌ Skip | ✅ Required |
- Always use DTOs for data transfer (prevents array abuse, ensures type safety)
- Always use Repositories for data access (decouples from Eloquent)
- Use Services when there's business logic (validation, calculations, side effects)
- Use Actions only for complex orchestration (multiple services, workflow)
- Follow YAGNI - don't add layers you don't need
- Start simple - add complexity only when needed
❌ Don't do this - Using all patterns for a simple operation:
// Over-engineered: Simple operation with unnecessary Action layer
class UpdateUserNameAction
{
public function __construct(private UserService $service) {}
public function execute(User $user, UserDTO $dto): User
{
return $this->service->updateName($user, $dto->name);
}
}
class UserService
{
public function updateName(User $user, string $name): User
{
return $this->repository->update($user, ['name' => $name]);
}
}✅ Do this instead - Direct service call:
class UserService
{
public function updateName(User $user, string $name): User
{
return $this->repository->update($user, ['name' => $name]);
}
}- No raw SQL unless required
- Always reversible (implement
down()method) - Use descriptive migration names
- Add indexes in migrations, not later
- Use foreign key constraints
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained()
->cascadeOnDelete();
$table->string('status')->index();
$table->decimal('total', 10, 2);
$table->timestamps();
$table->softDeletes();
$table->index(['user_id', 'status']);
});
Schema::dropIfExists('orders');-
Index:
- Foreign keys (automatic in Laravel)
- Search columns (WHERE, ORDER BY)
- Unique constraints
- Composite indexes for multi-column queries
-
Use
explain()to analyze query performance -
Avoid over-indexing (slows writes)
-
Avoid N+1 queries (
with(),load()mandatory) -
Use
select()to limit columns when needed -
Use
chunk()orcursor()for large datasets -
No logic in models except:
- Accessors
- Mutators
- Scopes
- Relationships
- Casts
// Good: Eager loading
$users = User::with('orders')->get();
// Good: Chunking
User::chunk(100, function ($users) {
foreach ($users as $user) {
// Process
}
});
// Good: Scopes
class User extends Model
{
public function scopeActive(Builder $query): Builder
{
return $query->where('status', 'active');
}
}- Use soft deletes for audit trails
- Always check for soft-deleted records in unique validations
- Use
withTrashed()when needed
class User extends Model
{
use SoftDeletes;
// In validation
Rule::unique('users')->whereNull('deleted_at')
}- Use Factories for test data
- Use Seeders for development data
- Keep factories realistic
class UserFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => Hash::make('password'),
];
}
public function suspended(): static
{
return $this->state(fn (array $attributes) => [
'status' => UserStatus::SUSPENDED,
]);
}
}- Prefer Eloquent for model operations
- Use Query Builder for complex queries or when Eloquent is inefficient
- Always use parameter binding (never raw strings)
// Good: Eloquent
User::where('status', 'active')->get();
// Good: Query Builder for complex queries
DB::table('users')
->join('orders', 'users.id', '=', 'orders.user_id')
->select('users.*', DB::raw('COUNT(orders.id) as order_count'))
->groupBy('users.id')
->get();- Primary Keys: Always use auto-incrementing integers or UUIDs
- Foreign Keys: Always indexed (automatic in Laravel)
- Composite Indexes: Order columns by selectivity (most selective first)
- Covering Indexes: Include frequently selected columns in indexes
- Partial Indexes: Use
whereclause in indexes for filtered queries (MySQL 8.0+)
// Composite index - order matters
$table->index(['status', 'created_at']); // Good if filtering by status first
// Covering index (includes columns in SELECT)
$table->index(['user_id', 'status', 'total']); // If frequently selecting these
// Partial index (MySQL 8.0+)
DB::statement('CREATE INDEX idx_active_users ON users (email) WHERE status = "active"');- Strings: Use appropriate length (
VARCHAR(255)vsTEXT) - Numbers: Use smallest appropriate type (
TINYINTfor status,DECIMALfor money) - Dates: Use
TIMESTAMPorDATETIMEappropriately - JSON: Use
JSONtype for structured data (MySQL 5.7+) - UUIDs: Use
CHAR(36)orBINARY(16)for UUIDs
// Good: Appropriate data types
$table->string('email', 255)->index();
$table->tinyInteger('status')->default(1); // 0-255 range
$table->decimal('price', 10, 2); // Money: 10 digits, 2 decimal places
$table->json('metadata'); // Structured data
$table->uuid('external_id')->index(); // UUID for external systems- Use EXPLAIN: Always analyze slow queries
- **Avoid SELECT ***: Select only needed columns
- Limit results: Use
limit()andoffset()appropriately - Use EXISTS: Prefer
exists()overcount()for existence checks - Avoid functions in WHERE: Don't use functions on indexed columns
// Bad: SELECT *
$users = User::all(); // ❌
// Good: Select specific columns
$users = User::select('id', 'name', 'email')->get(); // ✅
// Bad: Function in WHERE
User::whereRaw('YEAR(created_at) = ?', [2024])->get(); // ❌
// Good: Range query
User::whereBetween('created_at', ['2024-01-01', '2024-12-31'])->get(); // ✅
// Bad: Count for existence
if (User::where('email', $email)->count() > 0) { } // ❌
// Good: Exists check
if (User::where('email', $email)->exists()) { } // ✅- Use connection pooling in production
- Keep transactions short: Don't hold transactions open
- Use read replicas for read-heavy applications
- Monitor connection count: Avoid connection exhaustion
// Good: Short transaction
DB::transaction(function () {
$order = Order::create($data);
Payment::create(['order_id' => $order->id]);
// Transaction commits here
});
// Bad: Long transaction
DB::beginTransaction();
// ... long operation ...
// ... external API call ... // ❌ Don't do this in transaction
DB::commit();- Partition large tables by date or range
- Use partitioning for tables with millions of rows
- Partition by date for time-series data
// Partition by date (requires raw SQL)
DB::statement('
ALTER TABLE orders
PARTITION BY RANGE (YEAR(created_at)) (
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026)
)
');- Use FULLTEXT indexes for search functionality
- Use MySQL 5.6+ for InnoDB full-text search
- Consider Elasticsearch for complex search requirements
// Migration
$table->fullText(['title', 'description']);
// Query
User::whereFullText(['title', 'description'], 'search term')->get();- Enable slow query log: Monitor queries > 1 second
- Use query logging in development (never in production)
- Monitor index usage: Remove unused indexes
- Track table sizes: Monitor growth patterns
// In AppServiceProvider (development only)
if (app()->environment('local')) {
DB::listen(function ($query) {
if ($query->time > 1000) { // Log slow queries
Log::warning('Slow query', [
'sql' => $query->sql,
'time' => $query->time,
]);
}
});
}- Regular backups: Daily at minimum
- Test restores: Verify backup integrity
- Point-in-time recovery: Use binary logs
- Backup before migrations: Always backup before schema changes
- Use UTF8MB4: Full UTF-8 support (emojis, special characters)
- Consistent collation: Use
utf8mb4_unicode_cifor case-insensitive - Set at database level: Configure in
config/database.php
// config/database.php
'mysql' => [
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
],- Mandatory for multi‑step writes
- Use transactions for data consistency
- Handle transaction failures appropriately
- Consider transaction isolation levels when needed
// Basic transaction
DB::transaction(function () {
$order = $this->orderRepo->create(...);
$this->paymentRepo->capture($order, ...);
$this->inventoryRepo->decrement(...);
});
// With return value
$result = DB::transaction(function () {
return $this->service->processOrder(...);
});
// With manual control
DB::beginTransaction();
try {
$order = $this->orderRepo->create(...);
$this->paymentRepo->capture($order, ...);
DB::commit();
} catch (Throwable $e) {
DB::rollBack();
throw $e;
}- Pest (mandatory)
- Use Pest's higher-order expectations when possible
- Organize tests by feature/domain
- Unit tests: Test individual classes/methods in isolation
- Feature tests: Test HTTP endpoints and user flows
- Integration tests: Test interactions between components
// Feature Test
it('creates a user successfully', function () {
$response = postJson('/api/v1/users', [
'email' => 'test@example.com',
'password' => 'Password123',
'name' => 'Test User',
]);
$response->assertCreated()
->assertJsonStructure([
'data' => ['id', 'email', 'name'],
]);
$this->assertDatabaseHas('users', [
'email' => 'test@example.com',
]);
});
// Unit Test
it('calculates order total correctly', function () {
$order = new Order(['subtotal' => 100, 'tax' => 10]);
expect($order->total)->toBe(110.0);
});- Follow AAA pattern (Arrange, Act, Assert)
- Use descriptive test names
- One assertion per test when possible
- Use factories for test data
- Clean up after tests (use
RefreshDatabase)
uses(RefreshDatabase::class);
it('updates user email', function () {
// Arrange
$user = User::factory()->create();
$newEmail = 'new@example.com';
// Act
$response = putJson("/api/v1/users/{$user->id}", [
'email' => $newEmail,
]);
// Assert
$response->assertOk();
expect($user->fresh()->email)->toBe($newEmail);
});- Use Factories for model creation
- Use Seeders sparingly in tests
- Use
RefreshDatabasetrait for database tests - Use
DatabaseTransactionsfor performance when appropriate
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->actingAs($this->user);
});- Mock external services (APIs, payment gateways)
- Use
Mockeryor Pest's mocking helpers - Avoid over-mocking (prefer real implementations when possible)
it('sends email when user registers', function () {
Mail::fake();
postJson('/api/v1/users', [...]);
Mail::assertSent(WelcomeEmail::class);
});- Minimum 80% coverage
- Business logic must be unit tested
- Critical paths must have feature tests
- Use
--coverageflag to generate reports - Focus on meaningful coverage, not just numbers
./vendor/bin/pint- Must pass in CI
- No custom styles unless approved
- Use domain‑specific exceptions
- Create custom exception classes for business logic errors
- Use appropriate HTTP status codes
- Always provide meaningful error messages
// Custom Exception
class UserAlreadyExistsException extends Exception
{
public function __construct(string $email)
{
parent::__construct("User with email {$email} already exists.");
}
public function render(Request $request): JsonResponse
{
return response()->json([
'message' => $this->getMessage(),
], 409);
}
}
// Usage
if (User::where('email', $email)->exists()) {
throw new UserAlreadyExistsException($email);
}- Use global exception handler for consistent error responses
- Log exceptions with context
- Never expose sensitive information in error messages
- Use different log levels appropriately
// In App\Exceptions\Handler
public function register(): void
{
$this->renderable(function (UserAlreadyExistsException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json([
'message' => $e->getMessage(),
], 409);
}
});
}- Use structured logs with context
- Use appropriate log levels (debug, info, warning, error, critical)
- Never log sensitive data (passwords, tokens, credit cards)
- Use channels for different log types
// Good: Structured logging
Log::info('User registered', [
'user_id' => $user->id,
'email' => $user->email,
]);
Log::error('Payment failed', [
'order_id' => $order->id,
'amount' => $order->total,
'error_code' => $exception->getCode(),
]);
// Bad: Logging sensitive data
Log::info('User login', [
'password' => $request->password, // ❌ Never do this
]);- No secrets in code or version control
- Use
.envfor configuration (never commit.env) - Use Laravel's
config()helper, neverenv()directly in code - Rotate secrets regularly
- Use different keys for different environments
// Good: Use config
$apiKey = config('services.payment.api_key');
// Bad: Direct env access
$apiKey = env('PAYMENT_API_KEY'); // ❌- Use Laravel's built-in authentication
- Always use Policies or Gates for authorization
- Check authorization in controllers and form requests
- Use middleware for route-level protection
// Policy
class OrderPolicy
{
public function update(User $user, Order $order): bool
{
return $user->id === $order->user_id;
}
}
// In Controller
public function update(UpdateOrderRequest $request, Order $order)
{
$this->authorize('update', $order);
// ...
}- Always hash passwords using
Hashfacade - Use strong password requirements
- Never store plain text passwords
- Use password reset tokens securely
// Hashing
$hashed = Hash::make($password);
// Verification
if (Hash::check($password, $user->password)) {
// Valid
}- Always use
$fillableor$guardedin models - Never use
$request->all()without filtering - Use Form Requests with validated data
// Model
class User extends Model
{
protected $fillable = ['name', 'email', 'password'];
// OR
protected $guarded = ['id', 'is_admin'];
}
// Controller
public function update(UpdateUserRequest $request, User $user)
{
$user->update($request->validated()); // ✅ Safe
// NOT: $user->update($request->all()); // ❌
}- Always use Eloquent or Query Builder (parameter binding)
- Never use raw queries with user input
- Use
whereRaw()only with bindings
// Good: Parameter binding
User::where('email', $email)->first();
// Good: Raw with bindings
DB::select('SELECT * FROM users WHERE email = ?', [$email]);
// Bad: Raw without bindings
DB::select("SELECT * FROM users WHERE email = '{$email}'"); // ❌- Always escape output in Blade templates
- Use
{!! !!}only when necessary and data is trusted - Sanitize user input
// Blade (auto-escaped)
{{ $user->name }} // ✅ Safe
// Only when trusted
{!! $trustedHtml !!} // Use carefully- CSRF protection is enabled by default
- Always include
@csrfin forms - Use
VerifyCsrfTokenmiddleware for API routes if needed
- Use Laravel's rate limiting middleware
- Implement rate limits for authentication endpoints
- Use different limits for different user types
// In routes
Route::post('/login')->middleware('throttle:5,1'); // 5 attempts per minute- Always validate and sanitize user input
- Use Form Requests for validation
- Validate file uploads (type, size)
- Never trust client-side validation alone
- Always eager load relationships (
with(),load()) - Use
select()to limit columns when fetching large datasets - Use
chunk()orcursor()for processing large collections - Add database indexes for frequently queried columns
- Use
explain()to analyze slow queries - Avoid N+1 query problems
// Good: Eager loading
$users = User::with(['orders', 'profile'])->get();
// Good: Chunking
User::chunk(1000, function ($users) {
foreach ($users as $user) {
// Process
}
});
// Good: Selecting specific columns
User::select('id', 'name', 'email')->get();- Cache expensive operations
- Use appropriate cache drivers (Redis recommended for production)
- Set reasonable TTL values
- Invalidate cache when data changes
- Use cache tags when available
// Simple caching
$users = Cache::remember('users.active', 600, function () {
return User::where('status', 'active')->get();
});
// Cache with tags (Redis)
Cache::tags(['users', 'active'])
->remember('users.active', 600, fn () => ...);
// Cache invalidation
Cache::forget('users.active');
Cache::tags(['users'])->flush();- Use queues for heavy tasks (emails, file processing, API calls)
- Use appropriate queue drivers (Redis, database, SQS)
- Implement retry logic for failed jobs
- Use job batching for related operations
- Monitor queue performance
// Dispatch job
ProcessPayment::dispatch($order);
// With delay
SendEmail::dispatch($user)->delay(now()->addMinutes(5));
// Job batching
Bus::batch([
new ProcessOrder($order1),
new ProcessOrder($order2),
])->dispatch();- Avoid synchronous external API calls in request cycle
- Use HTTP client connection pooling
- Implement timeouts for external requests
- Cache API responses when appropriate
- Use async processing for non-critical API calls
// Use queues for external APIs
CallExternalAPI::dispatch($data);
// Or use async HTTP
Http::timeout(5)->get('https://api.example.com/data');- Avoid unnecessary loops
- Use collection methods efficiently
- Profile code to identify bottlenecks
- Use Laravel Debugbar in development (never in production)
- Optimize autoloading with
composer dump-autoload -o
(Laravel / PHP / MySQL / Distributed Systems)
If you can't measure it, you can't scale it.
All production systems MUST be observable by default. Observability is not logging alone — it consists of three pillars:
- Logs – What happened
- Metrics – How often / how long / how much
- Traces – Where time was spent
Every new feature, background job, or integration must be evaluated against all three pillars.
Use structured logging with correct severity:
| Level | Usage |
|---|---|
debug |
Development-only diagnostic info |
info |
Normal business events |
warning |
Recoverable issues |
error |
Failed operations requiring attention |
critical |
System-wide failures |
🚫 Never log:
- Plain text dumps
- Sensitive data (tokens, passwords, PII)
- Full request/response bodies in production
All logs must be structured (JSON) and include contextual metadata.
Required fields:
request_iduser_id(if available)service/app_nameenvironmentduration_ms(where applicable)
Log::info('Order placed', [
'order_id' => $order->id,
'user_id' => auth()->id(),
'request_id' => request()->header('X-Request-ID'),
]);Every incoming request must have a correlation/request ID:
-
Generated at the edge (NGINX / Load Balancer)
-
Propagated through:
- HTTP headers
- Queue jobs
- External API calls
This enables end-to-end tracing across services.
- Request count
- Error rate
- Latency (p50, p95, p99)
- Queue depth
- Job failure rate
- CPU, memory usage
- Disk I/O
- Network latency
- Orders created
- Payments processed
- Failed transactions
Each service must define:
- Availability SLI
- Latency SLI
- Error SLI
Example:
99.9% of API requests respond in < 300ms
Alerts MUST be:
- Actionable
- Time-bound
- Tied to customer impact
🚫 Do not alert on:
- Single errors
- Non-user impacting warnings
✅ Alert on:
- Error rate spikes
- Queue backlog growth
- SLA violations
Use distributed tracing to understand:
- Request flow
- Bottlenecks
- External service latency
Trace:
- Controllers
- DB queries
- External HTTP calls
- Queue jobs
For each job, track:
- Execution time
- Retry count
- Failure reason
- Payload size
public function handle(): void
{
$start = microtime(true);
try {
// Job logic
} finally {
Log::info('Job executed', [
'job' => self::class,
'duration_ms' => (microtime(true) - $start) * 1000,
]);
}
}Each endpoint must define:
- Max response time
- Max DB queries
- Max payload size
Example:
- API endpoint: ≤ 200ms
- DB queries: ≤ 10
- Payload: ≤ 50KB
- Use indexes intentionally
- Avoid
SELECT * - Use pagination for large datasets
- Avoid N+1 queries (use
with())
User::with('roles')->paginate(25);- Enable slow query logging
- Review slow queries regularly
- Use
EXPLAINfor heavy queries
Use caching by design, not as a patch.
- Request-level (memoization)
- Application-level (Redis)
- HTTP-level (CDN)
Cache::remember(
"user:{$id}",
now()->addMinutes(10),
fn () => User::findOrFail($id)
);Move non-critical tasks to queues:
- Emails
- Notifications
- Reports
- Webhooks
Never block user requests with:
- External APIs
- File processing
- Heavy DB operations
Before production:
- Simulate real traffic
- Identify breaking points
- Measure response degradation
Test scenarios:
- Peak traffic
- Burst traffic
- Dependency failures
Track:
- Requests/sec
- Jobs/sec
- DB connections
- Queue throughput
Use data to forecast:
- Infrastructure needs
- Cost optimization
- Scaling limits
All external calls must define:
- Timeouts
- Retry limits
- Backoff strategy
Http::timeout(3)
->retry(3, 200)
->get($url);When dependencies fail:
- Serve cached data
- Disable non-essential features
- Return meaningful errors
Performance should be reviewed during:
- Design reviews
- PR reviews
- Post-incident reviews
After incidents:
- Identify root cause
- Improve monitoring
- Prevent recurrence
No blame. Focus on systems, not people.
A feature is NOT DONE unless:
- Logs are structured
- Metrics are emitted
- Alerts are defined
- Performance budget is respected
- Failure scenarios are handled
Observability is not a tool — it's a design discipline.
Teams that bake observability into their systems ship faster, debug less, and scale with confidence.
- Use RESTful naming conventions
- Use appropriate HTTP methods (GET, POST, PUT, PATCH, DELETE)
- Use proper HTTP status codes
- Version all APIs
// Routes
Route::get('/api/v1/users', [UserController::class, 'index']);
Route::post('/api/v1/users', [UserController::class, 'store']);
Route::get('/api/v1/users/{user}', [UserController::class, 'show']);
Route::put('/api/v1/users/{user}', [UserController::class, 'update']);
Route::delete('/api/v1/users/{user}', [UserController::class, 'destroy']);- Use consistent response structure
- Always use API Resources for responses
- Include metadata for paginated responses
- Return appropriate status codes
// Success response
return response()->json([
'data' => new UserResource($user),
], 201);
// Paginated response
return UserResource::collection($users)->response()->getData(true);
// Returns: { data: [...], links: {...}, meta: {...} }
// Error response
return response()->json([
'message' => 'Validation failed',
'errors' => $validator->errors(),
], 422);- Version all APIs in URL path
- Maintain backward compatibility when possible
- Document breaking changes
- Use semantic versioning (v1, v2, etc.)
- Document all API endpoints
- Use OpenAPI/Swagger or similar
- Include request/response examples
- Document authentication requirements
- Keep documentation up to date
- Laravel Pint: Code formatting
- Pest: Test suite (must pass)
- PHPStan: Static analysis (level 8+)
- Coverage: Minimum 80% (must not decrease)
- Lint: Run Pint
- Analyze: Run PHPStan
- Test: Run Pest with coverage
- Build: Verify application builds
- Deploy: Automated deployment (if configured)
- No direct push to
main/master - Require pull request reviews
- Require status checks to pass
- Require branches to be up to date
- Require linear history (no merge commits)
- Run Pint before commit
- Run PHPStan on changed files
- Run relevant tests
-
PHPDoc required for:
- Public classes and methods
- Complex logic
- Non-obvious code
- Parameters and return types
-
Use meaningful docblocks
/**
* Creates a new user account.
*
* @param UserDTO $dto The user data transfer object
* @return User The newly created user
* @throws UserAlreadyExistsException If user with email already exists
* @throws ValidationException If validation fails
*/
public function createUser(UserDTO $dto): User
{
// Implementation
}- Document all API endpoints
- Include request/response examples
- Document authentication requirements
- Keep documentation synchronized with code
- Use tools like Laravel API Documentation or Swagger/OpenAPI
- Maintain up-to-date README.md
- Include setup instructions
- Document environment variables
- Include examples
- Document common tasks
- Use descriptive branch names
- Follow convention:
type/descriptionorticket-number/description - Types:
feature/,bugfix/,hotfix/,refactor/
feature/user-authentication
bugfix/payment-gateway-error
hotfix/security-patch- Use clear, descriptive commit messages
- Follow conventional commits format (optional but recommended)
- Reference ticket/issue numbers
- Keep commits focused and atomic
feat: add user registration endpoint
- Implement CreateUserAction
- Add UserDTO
- Add validation rules
- Add tests
Closes #123- Keep PRs small and focused
- One feature/fix per PR
- Include description and context
- Link to related tickets/issues
- Update documentation if needed
- Pin versions in
composer.json(avoid*) - Use
^for compatible versions - Regularly update dependencies
- Review security advisories
- Use
composer auditto check for vulnerabilities
{
"require": {
"laravel/framework": "^10.0",
"php": "^8.2"
}
}- Use PSR-4 autoloading
- Optimize autoloader in production:
composer dump-autoload -o - Never commit
vendor/directory - Always commit
composer.lock
- Prefer well-maintained packages
- Check package statistics (downloads, stars, last update)
- Review package code quality
- Prefer Laravel-ecosystem packages when available
- Use
.envfor local development - Use
.env.exampleas template (commit this) - Never commit
.envfile - Use different
.envfiles for different environments - Document all required environment variables
- Store configuration in
config/directory - Use
config()helper, neverenv()directly - Cache config in production:
php artisan config:cache - Use environment-specific config when needed
// config/services.php
return [
'payment' => [
'api_key' => env('PAYMENT_API_KEY'),
'api_url' => env('PAYMENT_API_URL', 'https://api.payment.com'),
],
];
// In code
$apiKey = config('services.payment.api_key');❌ Fat controllers - Controllers with business logic ❌ Business logic in views - Any logic beyond presentation ❌ God objects - Classes that do too much ❌ Anemic domain models - Models with no behavior ❌ Tight coupling - Direct dependencies on concrete classes ❌ Over-engineering - Using all patterns (Action + Service + Repository) for simple operations (violates YAGNI)
❌ Raw queries everywhere - Not using Eloquent/Query Builder ❌ Untyped arrays - Using arrays instead of DTOs ❌ Magic numbers/strings - Hard-coded values without constants ❌ Commented-out code - Dead code should be removed ❌ Copy-paste programming - Duplicated logic
❌ N+1 queries - Not eager loading relationships ❌ Missing indexes - On frequently queried columns ❌ No transactions - For multi-step operations ❌ Raw SQL injection risks - Not using parameter binding ❌ Over-eager loading - Loading unnecessary relationships
❌ Skipping tests - For "simple" code ❌ Flaky tests - Non-deterministic tests ❌ Testing implementation - Instead of behavior ❌ No test isolation - Tests affecting each other ❌ Mocking everything - Over-mocking
❌ Secrets in code - Hard-coded credentials ❌ No authorization checks - Assuming user permissions ❌ Mass assignment vulnerabilities - Not using fillable/guarded ❌ SQL injection - Raw queries with user input ❌ XSS vulnerabilities - Not escaping output
Every Pull Request must include the following checklist and all items must be satisfied before review approval.
- PR title is clear and descriptive
- PR scope is small and focused (single responsibility)
- Linked to ticket / task / issue
- No unrelated changes included
- Code follows PSR-12 and Laravel conventions
-
declare(strict_types=1);used where applicable - No duplicated logic
- No commented-out code
- Meaningful variable, method, and class names
- Controllers are thin
- Business logic resides in Services / Actions
- Database access only via Repositories
- DTOs used instead of raw arrays
- SOLID principles respected
- No forbidden anti-patterns introduced
- Queries reviewed for N+1 issues
- Proper indexes added (if applicable)
- Transactions used for multi-step writes
- No unnecessary eager loading
- Caching considered where appropriate
- Tests written using Pest
- New business logic has unit tests
- Feature tests updated/added
- Tests are deterministic (no flaky tests)
- Coverage does not decrease
- Laravel Pint passes
- PHPStan passes (no new errors)
- CI pipeline is green
- No secrets committed
- Authorization checked (Policies / Gates)
- Input validated via Form Requests
- Sensitive data not logged
Reviewers must evaluate PRs using the following rubric. Approval requires no Critical issues and all Major issues resolved.
| Level | Description |
|---|---|
| Critical | Must fix before merge |
| Major | Should fix before merge |
| Minor | Can fix later |
| Nit | Style or preference |
- Does the code do what the ticket describes?
- Are edge cases handled?
- Are error paths covered?
- Proper separation of concerns
- No business logic in controllers
- Correct use of services, actions, repositories
- Easy to extend without modification
- Clear naming
- Small, focused methods
- Self-explanatory code
- Adequate inline comments where needed
- Efficient database access
- No unnecessary loops or queries
- Background jobs used where appropriate
- Caching strategy considered
- Tests exist and are meaningful
- Tests assert behavior, not implementation
- Edge cases tested
- Proper authorization
- Input validation present
- No mass-assignment vulnerabilities
- No sensitive data exposure
- Conforms to this standards guide
- Matches existing patterns
- Tooling compliance (Pint, PHPStan, Pest)
| Condition | Decision |
|---|---|
| Any Critical issue | ❌ Reject |
| Major issues present | 🔄 Changes requested |
| Only Minor/Nits | ✅ Approve |
- Be constructive and specific
- Suggest improvements, not just problems
- Enforce standards consistently
- Focus on long-term maintainability
- Review within 24-48 hours when possible
- Approve promptly when standards are met
- Respond to all comments
- Push fixes promptly
- Do not dismiss feedback without justification
- Keep PRs up to date with
main - Request re-review after addressing feedback
- Be open to suggestions and improvements
If it is not tested, typed, documented, reviewed, and formatted — it is not production-ready.