DEV Community

Cover image for I was afraid to run queue jobs on a copy of production, so I built a Laravel package
Florin Cabuz
Florin Cabuz

Posted on

I was afraid to run queue jobs on a copy of production, so I built a Laravel package

A while ago I was chasing a bug that only happened in production. Renewal emails weren't going out, and a flag that should have been set on users after renewal simply wasn't.

I couldn't reproduce it locally with seeded data, so I did what a lot of us do: I cloned the production database to my machine, ran the queued jobs by hand, and sprinkled logs through the code to see where things went wrong.

Writing to that clone was fine. It's a copy, I wanted the jobs to set the flag so I could see what happened. What wasn't fine was the feeling in my stomach every time I ran a job:

What if this actually sends a renewal email to a real customer?

The clone had real email addresses in it. My local .env had a working mail driver. One wrong config and a real person gets an email about a subscription from my laptop. The same goes for an SMS, a Slack notification, or a call to a payment API.

So I built a package for exactly this situation: laravel-db-safe-mode.

What it does

Install it as a dev dependency:

composer require --dev cabuz/laravel-db-safe-mode
Enter fullscreen mode Exit fullscreen mode

It's never installed on your production servers (composer install --no-dev), and it turns itself off in the production environment anyway. Everywhere else, it's on, without you having to remember to enable it.

It has two modes.

debug: the one I needed for that bug. Database writes and the queue workers you start yourself run normally, so you can actually reproduce the problem. But nothing reaches real people:

  • every email, on every mailer, is redirected to your own inbox (or not sent at all and just logged)
  • notifications on channels like SMS or Slack are dropped
  • outbound HTTP calls through Laravel's Http client are blocked, except for hosts you allow
  • the scheduler stays off, so no cron fires behind your back
SAFE_MODE=debug
SAFE_MODE_MAIL_TO=me@company.com
Enter fullscreen mode Exit fullscreen mode
php artisan queue:work --once
Enter fullscreen mode Exit fullscreen mode

The job runs, the flag gets set (or doesn't, and now you know why), and the renewal email lands in your inbox. The original recipients are kept in an X-Safe-Mode-Original-Recipients header, so you can see who it would have gone to.

strict: for when you connect your local app straight to the live production database to look at something. Reads work, but every write throws a WriteBlockedException, queue workers and the scheduler refuse to start, and outbound mail, notifications and HTTP are blocked.

// Intentional writes still work when you need them:
SafeMode::allowWrites(function () use ($user) {
    $user->update(['is_premium' => true]);
});
Enter fullscreen mode Exit fullscreen mode

You don't have to pick a mode yourself

If SAFE_MODE isn't set, the package looks at your data on boot. It peeks at users.email: if any address isn't at example.com, example.org or example.net (what Laravel's factories generate), it assumes the data is real and goes strict. On normal seeded local data it goes debug and stays out of your way.

Every artisan command shows a banner so you always know where you are:

 DEBUG MODE on REAL DATA  writes ALLOWED, queue workers ALLOWED, scheduler blocked; mail, notifications & HTTP guarded
Enter fullscreen mode Exit fullscreen mode

And php artisan safe-mode:status prints the full picture.

A few things I learned building it

Blocking writes is harder than overriding insert(). My first version overrode insert(), update() and friends on the database connection. Then I added tests against real MySQL and PostgreSQL in CI and found two problems that SQLite had hidden:

  • On PostgreSQL, Eloquent's create() doesn't go through insert() at all. It runs insert ... returning id through a select method.
  • On MySQL with Laravel 11+, MySqlConnection::insert() has an extra parameter, so my override caused a PHP fatal error.

The fix was to check every query in the one method they all pass through, Connection::run(), and decide from the SQL itself whether it writes. That also catches data-modifying CTEs and writes hidden behind a leading comment.

Test against the databases your users run. SQLite in memory is great for speed, but it made me ship a package that crashed on the most common setup. The CI now runs against MySQL, MariaDB and PostgreSQL on every push, plus PHP 8.1–8.5 × Laravel 10–13.

What it doesn't cover

To be honest about the limits:

  • The HTTP guard covers Laravel's Http client. SDKs that bring their own HTTP client (for example some payment or SMS SDKs) aren't intercepted. Use test keys locally.
  • Real-data detection looks at one column. If your users live somewhere else, point it at the right table and column in the config.
  • debug allows writes on whatever database you're connected to. Don't leave it on when you connect to live production. That's what the banner is for.

Try it

The code, docs and config are on GitHub: cbz1991/laravel-db-safe-mode. It's MIT licensed and contributions are welcome.

If you've ever had that "did I just email a real customer?" moment, I'd love to hear how you handle it today.

Top comments (4)

Collapse
 
challan116ux profile image
challan116-ux •

The stomach feeling you describe is the correct signal — a production clone with a working mail driver is one config line away from contacting real people, and 'I will be careful' is not a control. Preserving the original recipients in a header is the detail that makes debug mode genuinely useful rather than just safe, because half of debugging a renewal job is verifying it would have gone to the right person. The debug/strict split also maps to two different jobs people conflate: reproducing a behavior needs writes and side effects contained, while inspecting live data needs writes to be impossible, not merely discouraged.

Collapse
 
cbz1991 profile image
Florin Cabuz •

Thanks, that's a really good way to put it: "I will be careful" is not a control. That's exactly why I wanted the package to turn itself on rather than rely on me remembering.

And yes, the recipients header came straight out of the bug I was chasing. Half the question was "would this have gone to the right person, at the right time?", so blocking the email wasn't enough, I needed to see who it was meant for.

The distinction you draw between reproducing behavior and inspecting live data is basically how I ended up with the two modes, though it took a real bug to make me see it. Out of curiosity, how do you handle this today: a dedicated staging copy, anonymized dumps, or something else?

Collapse
 
saqrelfirgany profile image
Ahmed ElFirgany •

In a production copy, the risky part for me is anything that leaves the box. Mail, SMS, webhooks, payment calls.

Faking mail is easy. A job that calls an HTTP API directly can still slip through. Does the tool block outgoing HTTP too, for example with Http::preventStrayRequests()?

Collapse
 
cbz1991 profile image
Florin Cabuz •

Yes, outgoing HTTP is blocked in both modes, but only what goes through Laravel's Http client.

It doesn't use Http::preventStrayRequests() (that's built around Http::fake() for tests). Instead it registers a global request middleware on the Http client: any request to a host that isn't allowlisted throws an exception, so a job calling Http::post('hooks.slack.com/...') fails loudly instead of firing. You can allow specific hosts in the config, e.g. a payment sandbox:

'http' => ['block' => true, 'allowed_hosts' => ['api.sandbox.example.com']],

The gap is exactly the one you're pointing at: SDKs that bring their own HTTP client (Stripe's PHP SDK uses curl directly, the AWS SDK uses its own Guzzle instance, same for some SMS providers) don't go through Laravel's Http client, so they're not intercepted. Same for a raw new GuzzleHttp\Client. For those, the only real protection today is test keys in your local .env.

Covering that layer properly is what I'd like to look at next. Do you have a pattern that works for you there? Container-level egress rules, swapping clients in the service container, something else?