PKTechie blog

Defensive Laravel: How I Write Safer Code for Production Systems

A practical look at defensive Laravel: validation, thin controllers, state transitions, webhooks, transactions, and the habits that make production code safer.

7 min read
laravelphpproductionwebhookssoftware-designtesting
Illustration representing defensive Laravel practices with code, workflows, and application safety concepts
The point of defensive Laravel is keeping production behaviour trustworthy when the happy path stops being the only path.

Why defensive Laravel matters once the app is real #

Laravel is one of the frameworks I enjoy using when a team needs to move quickly, and defensive Laravel matters because that speed only helps if the code still behaves properly under production pressure. You can build APIs quickly, wire up jobs and events, model data cleanly with Eloquent, and get useful features into production without spending weeks fighting the framework.

This article is about how I think about making Laravel systems safer in production, especially around validation, state changes, webhooks, transactions, and business rules.

But the production bugs that cause the most pain are usually not the flashy parts of the system. They are the operational parts. The webhook that arrives twice. The admin action that pushes a record into a state it should never reach. The payment flow that half succeeds. The date object that mutates quietly and breaks reporting a week later.

For me, defensive Laravel is not about making an app feel heavy. It is about making the codebase easier to trust when reality gets messy.

That means expecting retries, bad input, unclear state, partial failure, and future developers who will not know why the original code was written the way it was. Once I started looking at Laravel work through that lens, a lot of design choices became much easier to justify.

TL;DR: the habits I keep coming back to #

Quick takeaways

  • Validate input early so deeper code can assume a sane shape.
  • Keep controllers thin and put important rules somewhere reusable.
  • Protect state transitions explicitly instead of trusting the happy path.
  • Treat webhooks and retries like reliability-critical code.
  • Use transactions, stronger types, and tests to reduce hidden drift.

I do not see this as overengineering. I see it as respecting production.

1. Validate at the edge #

One of the simplest ways to make a Laravel application safer is to reject bad input early. I like validating data at the request boundary so the deeper parts of the app are not constantly guessing what shape the payload might be in.

<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class StoreDonationRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'supporter_email' => ['required', 'email'],
            'amount_cents' => ['required', 'integer', 'min:100'],
            'campaign_id' => ['required', 'integer', 'exists:campaigns,id'],
        ];
    }
}

This is not revolutionary code. It is just one of the most useful habits in a production system. If invalid data is blocked early, everything after that becomes easier to trust.

In real systems, this prevents

Controllers and service classes from turning into defensive guessing machines because no one knows whether the payload is complete, typed correctly, or even valid for the workflow.

2. Keep controllers thin #

I have seen a lot of Laravel apps where the controller quietly becomes the whole application. Validation, authorisation, business rules, state checks, notifications, and persistence all end up inside one method. It feels fast at first. It usually becomes brittle later.

I prefer controllers to act more like orchestration points. Accept the request. Authorise the action. Pass trusted data to an action or service. Return the response. If a rule matters, it should not only exist because one controller remembered to check it.

final class CaptureDonationAction
{
    public function execute(Donation $donation): void
    {
        if (! $donation->isPending()) {
            throw new DomainException('Only pending donations can be captured.');
        }

        $donation->markCaptured(now());
        $donation->save();
    }
}

That small shift makes rules easier to test, reuse, and reason about. More importantly, it stops the codebase from scattering the same rule across three different places with three slightly different outcomes.

3. Protect state transitions properly #

A lot of painful bugs are not missing-data bugs. They are state-transition bugs. A donation moves from failed to settled because retry logic was too loose. A user is marked verified before all checks are complete. A subscription is cancelled, but another process still treats it as active.

That is why I like explicit guard clauses around important transitions.

public function close(): void
{
    if ($this->isClosed()) {
        throw new DomainException('Account is already closed.');
    }

    if ($this->hasPendingTransactions()) {
        throw new DomainException('Account cannot be closed while transactions are pending.');
    }

    $this->closed_at = now();
    $this->status = AccountStatus::Closed;
}

What can go wrong

If these rules only live in a controller or a frontend button, another pathway will eventually bypass them. That is how admin panels lose trust and support teams end up fixing data by hand.

I trust systems more when the important transitions are visible and defended where the state actually changes.

4. Treat webhook handlers like reliability-critical code #

If an app talks to payment gateways, identity providers, CRMs, or external SaaS tools, webhook handling becomes one of the most important places to be defensive. Webhooks can be delayed, retried, duplicated, partially processed, or delivered out of order. That means a webhook endpoint is not just another controller.

use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($payload) {
    $eventId = $payload['id'];

    if (WebhookEvent::query()->where('provider_event_id', $eventId)->exists()) {
        return;
    }

    WebhookEvent::create([
        'provider_event_id' => $eventId,
        'type' => $payload['type'],
        'payload' => $payload,
    ]);

    // Continue processing safely...
});

In real systems, this prevents

Double-settled donations, replayed billing events, duplicate verification updates, and the slow loss of confidence that happens when external retries keep reapplying the same business event.

The exact domain changes. The defensive principle does not.

5. Use transactions where business consistency matters #

Laravel makes it easy to save records, update models, and dispatch side effects. That convenience is great, but it also makes it easy to leave the system in a half-finished state if something fails midway through the operation.

DB::transaction(function () use ($donation, $eventId) {
    $donation->status = 'captured';
    $donation->captured_at = now();
    $donation->save();

    PaymentAudit::create([
        'donation_id' => $donation->id,
        'external_event_id' => $eventId,
        'status' => 'captured',
    ]);
});

If multiple writes are part of one business action, I want them to succeed or fail together. Otherwise, the app starts disagreeing with itself, and those are the bugs that waste entire afternoons.

6. Stop overloading raw strings and integers #

Laravel projects often start simple. Status is a string. Amount is an integer. Provider reference is just another field. That works for a while. But once a value carries real business meaning, I think it deserves a bit more respect.

final class Money
{
    public function __construct(private int $amountCents)
    {
        if ($amountCents < 0) {
            throw new InvalidArgumentException('Amount cannot be negative.');
        }
    }

    public function value(): int
    {
        return $this->amountCents;
    }

    public function add(self $other): self
    {
        return new self($this->amountCents + $other->value());
    }
}

I am not saying every primitive should become a fancy class. But when a value becomes central to business logic, stronger modelling usually makes the code safer and easier to reason about.

7. Be careful with dates and side effects #

Dates are one of those things that look harmless until they are not. A date object gets changed in place, reused somewhere else, and suddenly an expiry rule, queue delay, or settlement window drifts away from what you thought the code meant.

use Carbon\CarbonImmutable;

$expiresAt = CarbonImmutable::now()->addDays(14);

if ($expiresAt->isPast()) {
    throw new DomainException('Verification window has already expired.');
}

I like immutable dates where timing matters because they remove a whole class of quiet side effects. That becomes especially valuable in reporting, billing cut-offs, refund windows, and subscription renewal logic.

8. Use Laravel's convenience, but add discipline #

One reason Laravel is so productive is that it gives you a lot of convenience out of the box. I do not want to lose that. But I do think good Laravel work comes from pairing convenience with discipline.

  • Form Requests for validation
  • Policies for authorisation
  • Action or service classes for workflows
  • Enums or clearer status modelling
  • Transactions for multi-step consistency
  • Queue-safe job design
  • Static analysis with PHPStan
  • Formatting and consistency with Pint
  • Tests and CI before deploy

That combination is what turns "it works" into "it holds up". I think about infrastructure and delivery in a similar way in my AWS field guide as well.

Final takeaway #

The older I get in software, the less impressed I am by clever code that only the original author understands. For me, defensive Laravel is really about trust. Not just whether the code works today, but whether it stays safe when real production pressure shows up.

That is what I want from production code. Another engineer should be able to change it without breaking something invisible. Support teams should be able to trust the admin state. Finance and operations should be able to trust the events and reports.

If you work on Laravel systems with payments, webhooks, admin workflows, or business-critical state changes, defensive thinking pays for itself very quickly. It is one of those habits that makes the codebase calmer six months later.

Quick FAQ

Quick answers to common questions

The main article goes deeper. This section keeps the most common questions short and easy to scan.

Is defensive Laravel the same as overengineering?
No. Overengineering adds complexity without enough payoff. The point of defensive Laravel is adding protection where the business risk is real.
Do I need value objects everywhere?
No. Use them where a value carries important business meaning or is easy to misuse as a raw primitive.
Where should business rules live?
Usually not only in controllers. Reusable actions, services, model methods, or domain-oriented classes are safer places for business rules to live.
What is one production issue defensive Laravel helps prevent?
Duplicate processing and invalid state. Webhooks, retries, and state changes are some of the biggest sources of pain in real systems.

Keep reading

If you like practical engineering write-ups, the AWS field guide and recent project work show the same production-first mindset from the infrastructure and delivery side.

Related posts

More practical reads

View all posts