Skip to content

Best Practices

Overview

This document outlines best practices for Laravel backend development. Following these practices improves code quality, security, performance, and maintainability.


Architecture & Design Patterns

Repository Pattern

Use repositories for complex data access logic:

php
// app/Repositories/UserRepository.php
namespace App\Repositories;

use App\Models\User;
use Illuminate\Database\Eloquent\Collection;

class UserRepository
{
    public function findActive(): Collection
    {
        return User::where('is_active', true)
            ->orderBy('created_at', 'desc')
            ->get();
    }

    public function findByEmail(string $email): ?User
    {
        return User::where('email', $email)->first();
    }

    public function create(array $data): User
    {
        return User::create($data);
    }
}

Service Layer Pattern

Encapsulate business logic in service classes:

php
// app/Services/User/UserService.php
namespace App\Services\User;

use App\Models\User;
use App\Repositories\UserRepository;
use App\Events\UserRegistered;
use Illuminate\Support\Facades\Hash;

class UserService
{
    public function __construct(
        protected UserRepository $repository
    ) {}

    public function register(array $data): User
    {
        $user = $this->repository->create([
            'name' => $data['name'],
            'email' => $data['email'],
            'password' => Hash::make($data['password']),
        ]);

        event(new UserRegistered($user));

        return $user;
    }

    public function updateProfile(User $user, array $data): User
    {
        $user->update($data);

        return $user->fresh();
    }
}

Action Pattern

For single-purpose operations, use action classes:

php
// app/Actions/User/CreateUserAction.php
namespace App\Actions\User;

use App\Models\User;
use Illuminate\Support\Facades\Hash;

class CreateUserAction
{
    public function execute(array $data): User
    {
        return User::create([
            'name' => $data['name'],
            'email' => $data['email'],
            'password' => Hash::make($data['password']),
        ]);
    }
}

Database Best Practices

Query Optimization

Eager Loading

php
// Bad - N+1 query problem
$users = User::all();
foreach ($users as $user) {
    echo $user->posts->count(); // Queries on each iteration
}

// Good - Eager load relationships
$users = User::with('posts')->get();
foreach ($users as $user) {
    echo $user->posts->count(); // No additional queries
}

// Even better - Count without loading
$users = User::withCount('posts')->get();
foreach ($users as $user) {
    echo $user->posts_count; // Uses aggregated count
}

Query Scopes

php
// app/Models/User.php
public function scopeActive($query)
{
    return $query->where('is_active', true);
}

public function scopeVerified($query)
{
    return $query->whereNotNull('email_verified_at');
}

public function scopeWithPosts($query)
{
    return $query->has('posts');
}

// Usage
$users = User::active()->verified()->withPosts()->get();

Select Only Needed Columns

php
// Bad - Fetches all columns
$users = User::all();

// Good - Fetch only needed columns
$users = User::select(['id', 'name', 'email'])->get();

Chunking Large Datasets

php
// Bad - Loads all users into memory
User::all()->each(function ($user) {
    // Process user
});

// Good - Process in chunks
User::chunk(100, function ($users) {
    foreach ($users as $user) {
        // Process user
    }
});

// Even better - Use lazy loading
User::lazy()->each(function ($user) {
    // Process user
});

Database Indexing

php
// database/migrations/2024_01_01_create_users_table.php
Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('email')->unique(); // Indexed automatically
    $table->string('phone')->nullable();
    $table->timestamps();

    // Add indexes for frequently queried columns
    $table->index('phone');
    $table->index(['created_at', 'is_active']); // Composite index
});

Use Transactions

php
use Illuminate\Support\Facades\DB;

// Wrap related operations in a transaction
DB::transaction(function () use ($data) {
    $user = User::create($data['user']);

    $profile = $user->profile()->create($data['profile']);

    $user->roles()->attach($data['roles']);
});

// With manual control
DB::beginTransaction();

try {
    $user = User::create($data);
    $order = $user->orders()->create($orderData);

    DB::commit();
} catch (\Exception $e) {
    DB::rollBack();
    throw $e;
}

Soft Deletes

php
// app/Models/Post.php
use Illuminate\Database\Eloquent\SoftDeletes;

class Post extends Model
{
    use SoftDeletes;
}

// Usage
$post->delete(); // Soft delete
$post->forceDelete(); // Permanent delete
$post->restore(); // Restore soft deleted

// Query soft deleted records
Post::withTrashed()->get();
Post::onlyTrashed()->get();

Security Best Practices

Input Validation

php
// Always validate user input
public function store(StoreUserRequest $request)
{
    // Validation happens automatically in FormRequest
    $validated = $request->validated();

    $user = User::create($validated);

    return new UserResource($user);
}

Mass Assignment Protection

php
// app/Models/User.php
class User extends Model
{
    // Option 1: Whitelist fillable fields
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    // Option 2: Blacklist guarded fields
    protected $guarded = [
        'id',
        'is_admin',
        'role',
    ];
}

SQL Injection Prevention

php
// Bad - Vulnerable to SQL injection
$users = DB::select("SELECT * FROM users WHERE email = '{$email}'");

// Good - Use parameter binding
$users = DB::select('SELECT * FROM users WHERE email = ?', [$email]);

// Best - Use Eloquent
$users = User::where('email', $email)->get();

XSS Prevention

php
// In Blade templates, {{ }} automatically escapes output
{{ $user->name }} // Safe

{!! $user->bio !!} // Unsafe - only use for trusted content

// Manually escape if needed
{{ e($untrustedContent) }}

CSRF Protection

php
// CSRF protection is automatic for POST, PUT, PATCH, DELETE
// Ensure VerifyCsrfToken middleware is active

// Exclude routes if needed (e.g., webhooks)
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
    'api/webhooks/*',
];

Password Hashing

php
use Illuminate\Support\Facades\Hash;

// Hash passwords
$user->password = Hash::make($request->password);

// Verify passwords
if (Hash::check($request->password, $user->password)) {
    // Password is correct
}

// Use Bcrypt or Argon2 (configured in config/hashing.php)

Rate Limiting

php
// routes/api.php
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});

// Apply to routes
Route::middleware(['throttle:api'])->group(function () {
    Route::get('/users', [UserController::class, 'index']);
});

// Custom rate limits
Route::middleware(['throttle:10,1'])->group(function () {
    Route::post('/login', [AuthController::class, 'login']);
});

Performance Best Practices

Caching

php
use Illuminate\Support\Facades\Cache;

// Cache data
$users = Cache::remember('users.active', 3600, function () {
    return User::where('is_active', true)->get();
});

// Cache with tags (Redis/Memcached only)
Cache::tags(['users', 'active'])->put('users.list', $users, 3600);
Cache::tags(['users'])->flush(); // Clear all user caches

// Cache individual models
$user = Cache::remember("user.{$id}", 3600, function () use ($id) {
    return User::find($id);
});

// Clear cache
Cache::forget('users.active');

Query Caching

php
// app/Models/User.php
use Illuminate\Database\Eloquent\Builder;

public function scopeCached(Builder $query, string $key, int $ttl = 3600)
{
    return Cache::remember($key, $ttl, function () use ($query) {
        return $query->get();
    });
}

// Usage
$users = User::active()->cached('users.active', 1800);

Queue Long-Running Tasks

php
// app/Jobs/ProcessReport.php
namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class ProcessReport implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Report $report
    ) {}

    public function handle(): void
    {
        // Process report...
    }
}

// Dispatch job
ProcessReport::dispatch($report);

// Dispatch with delay
ProcessReport::dispatch($report)->delay(now()->addMinutes(10));

// Dispatch to specific queue
ProcessReport::dispatch($report)->onQueue('reports');

Lazy Collections

php
// For large datasets, use lazy collections
User::cursor()->each(function ($user) {
    // Process one user at a time without loading all into memory
});

// Or use lazy()
User::lazy()->filter(function ($user) {
    return $user->is_active;
})->each(function ($user) {
    // Process filtered users
});

Optimize Asset Loading

php
// Use CDN for static assets
// config/app.php
'asset_url' => env('ASSET_URL', null),

// .env
ASSET_URL=https://cdn.example.com

API Best Practices

Versioning

php
// routes/api.php
Route::prefix('v1')->group(function () {
    Route::apiResource('users', V1\UserController::class);
});

Route::prefix('v2')->group(function () {
    Route::apiResource('users', V2\UserController::class);
});

Resource Transformers

php
// app/Http/Resources/UserResource.php
namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'created_at' => $this->created_at->toIso8601String(),

            // Conditional fields
            'role' => $this->when($request->user()->isAdmin(), $this->role),

            // Relationships
            'posts' => PostResource::collection($this->whenLoaded('posts')),
        ];
    }
}

Pagination

php
// Always paginate list endpoints
public function index()
{
    $users = User::paginate(15);

    return UserResource::collection($users);
}

// Custom pagination
$users = User::paginate($request->input('per_page', 15));

// Cursor pagination for better performance
$users = User::cursorPaginate(15);

API Error Responses

php
// Use consistent error format
return response()->json([
    'error' => [
        'code' => 'USER_NOT_FOUND',
        'message' => 'The requested user was not found.',
        'details' => ['user_id' => $id]
    ]
], 404);

Testing Best Practices

Feature Tests

php
// tests/Feature/UserTest.php
namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class UserTest extends TestCase
{
    use RefreshDatabase;

    public function test_user_can_be_created()
    {
        $response = $this->postJson('/api/v1/users', [
            'name' => 'John Doe',
            'email' => 'john@example.com',
            'password' => 'password123',
            'password_confirmation' => 'password123',
        ]);

        $response->assertStatus(201)
            ->assertJsonStructure([
                'data' => ['id', 'name', 'email']
            ]);

        $this->assertDatabaseHas('users', [
            'email' => 'john@example.com'
        ]);
    }
}

Unit Tests

php
// tests/Unit/UserServiceTest.php
namespace Tests\Unit;

use App\Services\UserService;
use App\Models\User;
use Tests\TestCase;

class UserServiceTest extends TestCase
{
    public function test_creates_user_with_hashed_password()
    {
        $service = new UserService();

        $user = $service->create([
            'name' => 'John',
            'email' => 'john@example.com',
            'password' => 'password123'
        ]);

        $this->assertInstanceOf(User::class, $user);
        $this->assertNotEquals('password123', $user->password);
    }
}

Database Factories

php
// database/factories/UserFactory.php
namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

class UserFactory extends Factory
{
    public function definition(): array
    {
        return [
            'name' => fake()->name(),
            'email' => fake()->unique()->safeEmail(),
            'password' => bcrypt('password'),
            'is_active' => true,
        ];
    }

    public function inactive(): self
    {
        return $this->state(fn (array $attributes) => [
            'is_active' => false,
        ]);
    }
}

// Usage in tests
$user = User::factory()->create();
$users = User::factory()->count(10)->create();
$inactiveUser = User::factory()->inactive()->create();

Code Quality Best Practices

Use Type Hints

php
// Always use type hints
public function createUser(string $name, string $email): User
{
    return User::create([
        'name' => $name,
        'email' => $email,
    ]);
}

Avoid Magic Numbers

php
// Bad
if ($user->login_attempts > 5) {
    // Lock account
}

// Good
const MAX_LOGIN_ATTEMPTS = 5;

if ($user->login_attempts > self::MAX_LOGIN_ATTEMPTS) {
    // Lock account
}

Use Enums (PHP 8.1+)

php
// app/Enums/UserRole.php
namespace App\Enums;

enum UserRole: string
{
    case ADMIN = 'admin';
    case USER = 'user';
    case MODERATOR = 'moderator';

    public function label(): string
    {
        return match($this) {
            self::ADMIN => 'Administrator',
            self::USER => 'User',
            self::MODERATOR => 'Moderator',
        };
    }
}

// Usage
$user->role = UserRole::ADMIN;

if ($user->role === UserRole::ADMIN) {
    // Admin logic
}

Use Named Arguments

php
// Makes code more readable
$user = User::create(
    name: 'John Doe',
    email: 'john@example.com',
    password: Hash::make('password'),
    is_active: true
);

Early Returns

php
// Bad
public function processUser(User $user)
{
    if ($user->isActive()) {
        if ($user->isVerified()) {
            // Process user
            return true;
        } else {
            return false;
        }
    } else {
        return false;
    }
}

// Good - Early returns reduce nesting
public function processUser(User $user): bool
{
    if (!$user->isActive()) {
        return false;
    }

    if (!$user->isVerified()) {
        return false;
    }

    // Process user
    return true;
}

Event-Driven Architecture

Events and Listeners

php
// app/Events/UserRegistered.php
namespace App\Events;

use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;

class UserRegistered
{
    use Dispatchable;

    public function __construct(
        public User $user
    ) {}
}

// app/Listeners/SendWelcomeEmail.php
namespace App\Listeners;

use App\Events\UserRegistered;
use App\Mail\WelcomeEmail;
use Illuminate\Support\Facades\Mail;

class SendWelcomeEmail
{
    public function handle(UserRegistered $event): void
    {
        Mail::to($event->user)->send(new WelcomeEmail($event->user));
    }
}

// Register in EventServiceProvider
protected $listen = [
    UserRegistered::class => [
        SendWelcomeEmail::class,
        CreateUserProfile::class,
        LogUserRegistration::class,
    ],
];

// Dispatch event
event(new UserRegistered($user));

Model Observers

php
// app/Observers/UserObserver.php
namespace App\Observers;

use App\Models\User;

class UserObserver
{
    public function creating(User $user): void
    {
        // Before creating
        $user->uuid = Str::uuid();
    }

    public function created(User $user): void
    {
        // After created
        event(new UserRegistered($user));
    }

    public function updated(User $user): void
    {
        // After updated
    }

    public function deleted(User $user): void
    {
        // After deleted
    }
}

// Register in AppServiceProvider
public function boot(): void
{
    User::observe(UserObserver::class);
}

Environment Configuration

Environment Variables

php
// .env
APP_ENV=production
APP_DEBUG=false
APP_URL=https://api.example.com

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=myapp
DB_USERNAME=root
DB_PASSWORD=secret

CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis

MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525

SENTRY_LARAVEL_DSN=your-sentry-dsn

Configuration Files

php
// config/services.php
return [
    'stripe' => [
        'key' => env('STRIPE_KEY'),
        'secret' => env('STRIPE_SECRET'),
    ],

    'aws' => [
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
    ],
];

// Access in code
$stripeKey = config('services.stripe.key');

Dependency Injection

php
// Use constructor injection
class UserController extends Controller
{
    public function __construct(
        protected UserService $userService,
        protected UserRepository $repository
    ) {}

    public function index()
    {
        return $this->userService->getAll();
    }
}

// Method injection for specific methods
public function store(StoreUserRequest $request, UserService $service)
{
    return $service->create($request->validated());
}

Logging Best Practices

php
use Illuminate\Support\Facades\Log;

// Use appropriate log levels
Log::debug('Debug information', ['data' => $data]);
Log::info('User logged in', ['user_id' => $user->id]);
Log::warning('API rate limit approaching', ['remaining' => 10]);
Log::error('Payment failed', ['error' => $e->getMessage()]);
Log::critical('Database connection lost');

// Add context
Log::withContext([
    'user_id' => auth()->id(),
    'request_id' => request()->header('X-Request-ID'),
]);

// Use channels
Log::channel('slack')->critical('System is down!');


Quick Checklist

  • [ ] Use repositories for data access
  • [ ] Encapsulate business logic in services
  • [ ] Eager load relationships to avoid N+1
  • [ ] Add database indexes for frequently queried columns
  • [ ] Use transactions for related operations
  • [ ] Validate all user input
  • [ ] Protect against mass assignment
  • [ ] Hash passwords with bcrypt/argon2
  • [ ] Implement rate limiting on sensitive endpoints
  • [ ] Cache frequently accessed data
  • [ ] Queue long-running tasks
  • [ ] Version your APIs
  • [ ] Write tests for critical functionality
  • [ ] Use type hints everywhere
  • [ ] Handle errors gracefully
  • [ ] Log important events
  • [ ] Use dependency injection
  • [ ] Keep controllers thin
  • [ ] Follow SOLID principles

CPR - Clinical Patient Records