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 

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 
php artisan queue:work --once 

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]); }); 

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 

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.