Maintaining Laravel Elegance Under Business Pressure: A Guide to Clean Architecture
Overview
Software development is a balancing act between the pursuit of technical excellence and the unrelenting demands of business stakeholders. In the
Prerequisites
To get the most out of this guide, you should have a solid foundation in the following:
- PHP 8.2+: Familiarity with modern PHP features like type hinting, attributes, and enums.
- Laravel Framework: Understanding of the Request-Response lifecycle, Controllers, and EloquentORM.
- Basic Testing Concepts: Awareness of automated testing and the differences between feature and unit tests.
Key Libraries & Tools
- Laravel: The primary PHP framework used for building expressive web applications.
- Pest: A delightful PHP testing framework focused on simplicity and readability.
- PHPUnit: The industry-standard testing framework for PHP.
- Laravel Shift: An automated service for upgradingLaravelapplications and generating test boilerplate.
- PHPStan: A static analysis tool that finds bugs in your code without writing tests.
- Laravel Pint: An opinionated PHP code style fixer forLaravel.
Code Walkthrough: Cleaning the Controller Junk Drawer
One of the most common signs of a decaying application is the "Fat Controller." As business needs evolve, we often add custom methods to our controllers that fall outside the standard CRUD lifecycle. This turns a once-focused class into a junk drawer of unrelated logic.
1. Embracing Resourceful Controllers
Instead of adding custom methods like markAsPaid() to an InvoiceController, we should lean into
// Instead of this in InvoiceController:
public function markAsPaid(Invoice $invoice)
{
$invoice->update(['status' => 'paid']);
return back();
}
We should extract this into an invocable controller. This keeps the primary InvoiceController strictly limited to index, create, store, show, edit, update, and destroy.
namespace App\Http\Controllers;
use App\Models\Invoice;
use Illuminate\Http\Request;
class InvoicePaymentController extends Controller
{
public function __invoke(Request $request, Invoice $invoice)
{
$invoice->markAsPaid();
return back()->with('status', 'Invoice paid!');
}
}
2. Moving Validation to Form Requests
Validation often takes up significant vertical space in controller methods. By moving this logic to a
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;
use App\Enums\InvoiceStatus;
class StoreInvoiceRequest extends FormRequest
{
public function rules(): array
{
return [
'client_id' => ['required', 'exists:clients,id'],
'amount' => ['required', 'numeric', 'min:0'],
'status' => ['required', new Enum(InvoiceStatus::class)],
];
}
}
In your controller, you simply type-hint the request:
public function store(StoreInvoiceRequest $request)
{
Invoice::create($request->validated());
return redirect()->route('invoices.index');
}
3. Eliminating Magic Strings with Enums
Magic strings are "typo time bombs." Hard-coding statuses like 'pending' throughout your app makes refactoring impossible. Native PHP enums provide type safety and allow
namespace App\Enums;
enum InvoiceStatus: string
{
case Draft = 'draft';
case Pending = 'pending';
case Paid = 'paid';
case Cancelled = 'cancelled';
}
Cast the attribute in your
protected function casts(): array
{
return [
'status' => InvoiceStatus::class,
];
}
Advanced Eloquent: Scopes Over Repositories
Many developers reach for the Repository Pattern to abstract query logic. In
The Problem with Boolean Flags
Avoid methods that take multiple boolean flags, such as getInvoices(true, false, true). These are unreadable for humans. Instead, use chainable scopes that describe the business intent.
// Using new Laravel 12 Scoped Attribute syntax
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
#[Scoped]
protected function overdue(Builder $query): void
{
$query->where('due_date', '<', now());
}
#[Scoped]
protected function forClient(Builder $query, int $clientId): void
{
$query->where('client_id', $clientId);
}
You can then chain these in your controller for maximum readability:
$invoices = Invoice::overdue()->forClient($id)->get();
Syntax Notes
- Invocable Controllers: Using the
__invokemethod allows a controller to handle exactly one action, which is perfect for specialized business logic. - Docblocks vs. Native Types: Prefer native PHP type hints (e.g.,
string $name) over docblocks. Only use docblocks when the native type system cannot express the complexity (e.g., generics or specific array shapes). - Attribute-based Scopes: Laravel12 introduces attributes for scopes, allowing you to define them as protected methods without the
scopeprefix, further cleaning up the model's public API.
Practical Examples: The Clearance Envelope
In engineering, a "clearance envelope" is a zone around a moving object (like a roller coaster) that must remain unobstructed. Your code should have a similar envelope provided by automated tests. Before shipping a feature, use
// Pest Example: Testing an edge case
it('allows admins to see all invoice statuses', function () {
$admin = User::factory()->admin()->create();
$response = $this->actingAs($admin)
->get('/api/invoice-statuses');
$response->assertJson(InvoiceStatus::cases());
});
Tips & Gotchas
- The Debt Trap: Choosing convenience over cleanliness is a loan against your future productivity. The interest on that debt compounds until the application is impossible to maintain.
- The "Permission to be Messy" Rule: It is okay to write "garbage" code while you are still discovering the business requirements. However, you must take out the trash (refactor) before the code reaches production.
- Selling Clean Code: Never ask a stakeholder for "time to refactor." Instead, sell them on "velocity." Explain that cleaning a specific module will allow the team to ship features in 3 days instead of 3 weeks. Align technical elegance with business deliverability.
- Avoid TODOs: Comments like
// TODO: Fix this hackare rarely addressed. If a task is worth doing, do it now. If it's too big, create a failing test with$this->todo()inPestto keep it visible in your CI pipeline.
