DEV Community

Cover image for Seven things production taught me that no tutorial did
Varun Markanday
Varun Markanday

Posted on

Seven things production taught me that no tutorial did

I spent the last few months building out a shipping integration on top of Shippo, and along the way, I kept running into the same lesson: the "quick" way to write Laravel code almost always works — right up until it doesn't. This isn't a theory post. Every pattern below came from something that actually broke, or almost broke, in this codebase.

Here are seven things I'd tell a past version of myself before writing another shipment controller.

1. Stop passing raw arrays to third-party APIs.
The ticket says "wire up Shippo shipment creation." The fastest way to close it is to grab a few fields off the order, throw them into an array, and fire it at the HTTP client. It works on the first try. It works in the demo. So what's wrong with it?

Nothing — until six months from now, when a new dev who's never opened Shippo's docs calls your method, forgets that parcels need a distance_unit key, and finds out the hard way via a 422 in staging. The array approach doesn't fail where the mistake happens. It fails at the network boundary, with a stack trace pointing at your HTTP client instead of the line that actually got it wrong.

My first instinct was "Shippo owns this shape, so a DTO is just re-describing their API." That's backwards. The DTO isn't there to constrain Shippo — it's there to constrain me. It makes it structurally impossible to send Shippo something malformed, and it gives the next person a single file that answers "what does a shipment request actually need?"

phpfinal readonly class ShippoParcel
{
public function __construct(
public float $lengthCm,
public float $widthCm,
public float $heightCm,
public float $weightKg,
) {}

public function toPayload(): array
{
    return [
        'length' => (string) $this->lengthCm,
        'width' => (string) $this->widthCm,
        'height' => (string) $this->heightCm,
        'distance_unit' => 'cm',
        'weight' => (string) $this->weightKg,
        'mass_unit' => 'kg',
    ];
}
Enter fullscreen mode Exit fullscreen mode

}

toPayload() is doing something specific: it's the one place in the codebase that knows Shippo wants distance_unit and mass_unit as separate string keys instead of floats. That's a Shippo quirk, not a domain concept, and it deserves to live exactly once — at the edge — instead of being copy-pasted into every place that builds a parcel array.

Do the same thing on the response side. If a successful transaction contains a tracking_number buried three levels deep, don't make every consumer memorize that. Wrap it in a response DTO and throw a domain exception right there if a field you depend on is missing, instead of discovering it three calls later when something null-derefs.

2. Contextual binding: stop putting environment checks inside your classes.

Most of the time, one global container binding is exactly right. Don't reach for contextual binding before you need it — that's its own flavor of overengineering. But there's a specific smell that tells you when you've outgrown it: a service class that checks config() or does an instanceof check to decide its own behavior. That class just took on a second job — deciding which implementation it should be — and that job belongs to the container.

Here's a real one from this project: staging needs to hit Shippo's actual sandbox API so QA can test the full flow. But the internal support tool — the one an ops person uses to reprint a lost label — needs live credentials even when it's deployed next to staging, because a support agent reprinting a real label needs a real tracking number. An APP_ENV check can't express that; both consumers live in the same deployment.

php$this->app->when(QaShipmentController::class)
->needs(ShippoClient::class)
->give(SandboxShippoHttpClient::class);

$this->app->when(SupportLabelController::class)
->needs(ShippoClient::class)
->give(ShippoHttpClient::class);

Both controllers just type-hint ShippoClient. Neither has an if branch. Neither can drift onto the wrong credentials because someone forgot an env check.

One thing I'd flag: when you use the closure form of give() to resolve something like a region-specific storage driver, make the unmatched case throw instead of silently falling back to a default region.

php$this->app->when(LabelArchiver::class)
->needs(StorageDriverInterface::class)
->give(function ($app) {
return match (config('services.region')) {
'eu' => $app->make(S3EuStorageDriver::class),
'us' => $app->make(S3UsStorageDriver::class),
default => throw new RuntimeException('Unconfigured region: no storage driver bound.'),
};
});

A misconfigured deployment failing loudly at boot is a five-minute fix. The same misconfiguration that silently archives EU customer data to a US bucket for three weeks is a very different conversation with legal.

3. API versioning: the database doesn't get an opinion

The tempting shortcut when a mobile endpoint needs to change: just change it, ship a new app version, tell stragglers to update. It's less work than standing up parallel routes and resources for what might be a one-field difference.

Here's why that doesn't survive contact with mobile clients: you can't force an update. A web deploy hits everyone within minutes. A phone with your app from fourteen months ago, sitting in a drawer half the year, hits your API tomorrow with the same confidence as someone on today's build. You can't patch that client — you can only decide, in advance, how long you're willing to keep talking to it.

The rule I hold myself to now: version at the boundary, never inside business logic. The second a version check shows up inside a service class, that check is going to outlive the version it was written for.

phpfinal class ShipmentController extends V1\ShipmentController
{
protected function resource(Shipment $shipment): JsonResource
{
return ShipmentResource::make($shipment);
}
}

If v2 only changes the response shape, don't fork the whole controller — fork the resource and let v2 inherit the rest. Duplicating the entire controller feels safer in the moment, but now every bug fix has to be applied twice by someone who has to remember both files exist.

And tell clients a version is dying using something machine-readable, not a changelog nobody on the client side reads:

php$response->headers->set('Deprecation', 'true');
$response->headers->set('Sunset', 'Wed, 01 Oct 2026 00:00:00 GMT');

Then actually count requests against the old version after your sunset date. That count is the only honest answer to "can we turn this off yet" — not a guess from support tickets.

The part that actually burns teams isn't the routing layer; it's migrations. Add new columns as nullable, write to both old and new during the transition, confirm traffic on the old version has actually dropped to zero, and only then drop the legacy column in a separate deploy. "Low" traffic isn't zero traffic — one enterprise customer's warehouse scanner still hitting v1 nightly is enough to turn a routine cleanup into an on-call incident.

4. Real-time dashboards: broadcasting demos beautifully and then lying to you

Before reaching for WebSockets at all — just poll. setInterval, a fetch call, done. For a lot of internal dashboards, that's genuinely the right answer, not a lesser one. Broadcasting earns its complexity when both the update volume and the number of watchers climb.

What the "add a trait, save a model, watch it update live" demo doesn't show you: broadcasts that silently never arrive, events that land out of order, and clients that missed a whole window of updates because their laptop slept for two minutes.

A shipment tracking dashboard hits all three because "label purchased" and "in transit" webhooks can land milliseconds apart, and normal network jitter is enough to flip that order by the time it reaches a browser. The fix is a version field that the client can compare against:

phppublic function broadcastWith(string $event): array
{
return match ($event) {
'updated' => [
'id' => $this->id,
'status' => $this->status,
'version' => $this->updated_at->getTimestampMs(),
],
default => [],
};
}
jsEcho.private(shipments.${shipmentId}).listen('.ShipmentUpdated', (e) => {
if (e.version <= (lastSeenVersion[e.id] || 0)) return; // stale, ignore
lastSeenVersion[e.id] = e.version;
updateDashboard(e.status, e.trackingNumber);
});

The one everybody skips: reconnects. A dropped socket has zero memory of what it missed — there's no replay buffer. On reconnect, refetch the current state from a plain REST endpoint before trusting the next broadcast. If you skip this, you'll eventually get a bug report that reads "the dashboard says pending but it actually shipped hours ago," and it'll take you a minute to realize it's not a broadcasting bug at all — it's a missing REST call.

5. N+1 queries hide behind conditionals, not in obvious loops

Nobody writes an obvious N+1 loop and misses it in review. The ones that ship are nested two or three levels deep inside a branch that only fires for a subset of rows:

php$shipments = Shipment::with('customer')->get();
foreach ($shipments as $shipment) {
if ($shipment->customer->isHighVolume()) {
// N+1 fires here — parcels were never eager loaded
$shipment->parcels->each(fn ($p) => $p->carrier->name);
}
}

with('customer') only reaches one level deep. This is invisible in review unless someone mentally traces every branch two relations deep, and invisible in local dev because your ten-seeded shipments run ten queries instead of ten thousand. It shows up as connection pool exhaustion in production, during your busiest hour — not as a comment in a PR.

The actual fix isn't "remember to eager load" — memory isn't an engineering control. Make the framework refuse to ship the mistake:

phpModel::preventLazyLoading(! $this->app->isProduction());
Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation): void {
logger()->warning("Attempted to lazy load [{$relation}] on model [" . get_class($model) . "].");
});

Throw in local and staging, where a broken deploy costs nothing. Log-and-allow in production, where throwing on a lazy load would turn a performance bug into an outage on the spot. And enable this in your test suite too — a test that seeds exactly one shipment with one parcel will never trip an N+1, because there's nothing to multiply.

For the fix itself, reach for loadMissing() instead of sprinkling with() everywhere defensively. It costs nothing if the caller already did the right thing, and acts as a safety net at every controller/job boundary if they didn't.

6. Cache::flush() is a sledgehammer with a hair trigger

A carrier's negotiated rate changes, something needs invalidating, and Cache::flush() is sitting right there — one line, guaranteed correct, impossible to get subtly wrong. It also wipes every unrelated cached value in your app and triggers a stampede as every concurrent request misses at once and hits the database simultaneously.

Cache tags scope the blast radius to exactly what changed:

phpCache::tags(['rates', "carrier:{$carrierId}"])->put(
"rate.{$originZip}.{$destZip}", $rateQuote, now()->addHours(6)
);

Cache::tags(["carrier:{$carrierId}"])->flush();

Wire the flush to the model lifecycle so it's correct by construction instead of by every developer remembering to add an invalidation line next to every write:

phpprotected static function booted(): void
{
static::saved(function (CarrierAccount $account): void {
if ($account->wasChanged('negotiated_rate_table')) {
Cache::tags(["carrier:{$account->id}"])->flush();
}
});
}

Two things worth knowing before you build around this: cache tags require Redis or Memcached — the file and database drivers throw at runtime, not fail gracefully. And flush after your transaction commits, not before. Flush too early, and a request landing in that window reads the pre-update row, caches it, and now you're serving stale data for the full TTL with nothing telling you it happened.

Laravel 13 also adds Cache::touch(), which is worth knowing about for the more common case: a key is still valid, and you just want to push the TTL out without paying to rewrite the payload.

7. Model::all() works fine until your table grows

It's one call, it reads clean, and it's the answer in every tutorial. On a few hundred rows, it's genuinely correct. On a table with hundreds of thousands of open shipments, it's an OOM before your loop runs a single iteration — and the failure has nothing to do with your loop logic. It happens at the query level.

The choice comes down to two questions: does your loop mutate the column you're filtering on, and is each iteration doing slow I/O?

  • Read-only, fast → chunk()

  • Mutates the filtered column → chunkById() — pages by primary key instead of offset, so a row changing status mid-loop can't silently shift what the next page fetches (this is the one that bites people: chunk() under mutation doesn't error, it just quietly skips rows)

  • Slow outbound I/O → lazyById() — same generator ergonomics as cursor(), but doesn't hold a database connection open for the full duration of every outbound API call

phpShipment::where('status', 'in_transit')
->lazyById(500)
->each(function (Shipment $shipment): void {
$shipment->refreshTrackingStatus();
$shipment->save();
});

Get the second question wrong, and you trade an OOM for silently under-processing your table — which is arguably worse, because nothing throws to tell you it happened.

None of these is exotic. They're all things Laravel gives you out of the box. The theme across all seven, if there is one, is the same: local dev and a small seed dataset will hide every one of these problems from you, and production will find every single one at the worst possible time. Building the guardrail in — a DTO, a container binding, a lazy-loading exception, a version header — beats trying to remember not to make the mistake.

If you've hit a different version of any of these, I'd genuinely like to hear it — drop it in the comments.

Top comments (0)