Boring Tech: When a MySQL Table Beats Your SaaS Subscription
Half the SaaS tools I see on client invoices are just a cron job and a database table wearing a trench coat. Here's how to know when to stub it yourself — and when to stop.
A client came to me last year paying $340/month for a scheduled notification service that fired emails when lab results crossed a threshold. I looked at their stack — Laravel, MySQL, Forge — and had the same thing running as a native feature in about four hours. The SaaS vendor had a beautiful dashboard. I had a notifications table and a cron entry. The client kept the $340.
I'm not anti-SaaS. I run managed hosting. I love recurring revenue. But I've integrated enough third-party tools to recognize the pattern: a whole category of SaaS products are just a database table, a cron job, and a webhook wearing a $299/month trench coat. Knowing when to stub it yourself — and when to stop — is one of the more valuable instincts you can develop.
What "boring tech" actually means here
The phrase comes from Dan McKinley's old essay about choosing boring technology. The gist: novelty has a cost, and you only get so many innovation tokens per project. Spend them wisely.
I've extended that thinking to SaaS dependencies. Every external service you add is a novelty token. It's a new auth scheme to manage, a new failure mode to handle, a new line on the invoice, a new vendor to call when something breaks at 2am. Sometimes the capability genuinely justifies that cost. Often it doesn't.
The features most commonly over-SaaS'd, in my experience:
- Scheduled notifications (email/SMS on a trigger or time interval)
- Job queues and background processing
- Simple feature flags
- Audit logs / activity feeds
- Rate limiting
- Webhook fanout (broadcasting one event to multiple internal consumers)
- Lightweight CMS / content scheduling
Every one of those has a mature SaaS vendor. Every one of them can also be a MySQL table and a cron job for a surprising number of real-world use cases.
A concrete example: scheduled notifications
Here's what I actually shipped for that biotech client. The requirement: when a sample result record's value crosses a configured threshold, notify the assigned researcher by email, at most once per 24 hours.
The table:
CREATE TABLE threshold_notifications (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
sample_result_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
threshold_value DECIMAL(10, 4) NOT NULL,
actual_value DECIMAL(10, 4) NOT NULL,
notified_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_notified_at (notified_at),
INDEX idx_sample_result (sample_result_id)
);
The Artisan command:
<?php
namespace App\Console\Commands;
use App\Mail\ThresholdAlertMail;
use App\Models\ThresholdNotification;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class SendThresholdNotifications extends Command
{
protected $signature = 'notifications:thresholds';
protected $description = 'Notify researchers when sample results cross configured thresholds.';
public function handle(): int
{
$pending = ThresholdNotification::query()
->whereNull('notified_at')
->orWhere('notified_at', '<', now()->subHours(24))
->with(['sampleResult', 'user'])
->limit(500)
->get();
foreach ($pending as $notification) {
Mail::to($notification->user->email)
->send(new ThresholdAlertMail($notification));
$notification->update(['notified_at' => now()]);
}
$this->info("Sent {$pending->count()} threshold notifications.");
return Command::SUCCESS;
}
}
In app/Console/Kernel.php:
$schedule->command('notifications:thresholds')->everyFiveMinutes();
That's it. No API key. No webhook endpoint to secure. No vendor dashboard to explain to the client. No composer require vendor/saas-sdk pulling in 14 dependencies. If something breaks, I open the threshold_notifications table and I can see exactly what happened.
The SaaS alternative had retry logic, delivery receipts, and a visual rule builder. The client needed none of it. They needed an email when a number got too high.
Feature flags: same story
I keep seeing LaunchDarkly on invoices for apps with four internal users and a handful of flags. Here's what I actually use for clients who need basic flag support without the $200/month entry point:
CREATE TABLE feature_flags (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
enabled TINYINT(1) NOT NULL DEFAULT 0,
description TEXT NULL,
enabled_for JSON NULL, -- optional: ["user_id:42", "role:admin"]
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
class Flag
{
public static function enabled(string $name, ?User $user = null): bool
{
$flag = cache()->remember(
"feature_flag:{$name}",
now()->addMinutes(5),
fn () => FeatureFlag::where('name', $name)->first()
);
if (! $flag || ! $flag->enabled) {
return false;
}
if ($user && $flag->enabled_for) {
$targets = collect($flag->enabled_for);
return $targets->contains("user_id:{$user->id}")
|| $targets->contains("role:{$user->role}");
}
return true;
}
}
Five-minute cache means a flag change propagates fast enough for anything I've shipped. Blade, middleware, API responses — all just call Flag::enabled('new-billing-ui', $user). A simple Nova resource or even a Tinker command is enough of an admin interface for most teams.
Is it LaunchDarkly? No. Does it need to be? Usually not.
The three signs it's time to stop
Okay, so when does this pattern break down? I've hit the wall enough times to know the three clear signals.
1. Your cron job is doing work that should be event-driven at scale.
Polling works until it doesn't. If you're processing 50 records every five minutes, a cron job is fine. If you're processing 50,000 records and latency matters — users are waiting on the result — you've outgrown the stub. This is when I actually reach for a proper queue worker (Laravel Horizon + Redis, still boring tech, but purpose-built) or in extreme cases a message broker. The cron-plus-table pattern has no backpressure. It will just fall further and further behind.
2. You need delivery guarantees your stub can't provide.
My threshold notification example is fine for "researcher gets an email eventually." It is not fine for "this payment confirmation must be delivered exactly once and we need a receipt for compliance." Healthcare billing, financial transactions, anything with a regulatory audit trail — that's when the vendor's retry logic, delivery receipts, and compliance certifications are actually worth the invoice line. I've seen DIY notification systems silently drop messages when a mail server hiccuped and nobody noticed for a week. Know your tolerance.
3. You're rebuilding the vendor's dashboard.
This is the sneaky one. You stub the feature, it works great, and then the client asks for visibility into it. Fine, you add a Nova resource. Then they want filtering. Then export. Then graphs. Then a status page. You wake up six months later and you've built half of a SaaS product inside your client's Laravel app, and it's your problem to maintain forever. If the stakeholders are going to want rich operational visibility into the feature, price the SaaS vendor honestly against your ongoing maintenance cost — not just the build cost.
When I'd reach for this pattern
I stub it myself when:
- The feature is genuinely simple and the vendor is charging for complexity I don't need
- The data lives in my database anyway and a JOIN is simpler than an API call
- The client is cost-sensitive and the SaaS price doesn't match the value delivered
- I control the full stack and don't need a third party in my failure domain
- The "feature" is really just a query and an email template
I reach for the SaaS tool when:
- Compliance or SLAs make the vendor's guarantees worth paying for
- The feature has real depth I'd spend months reimplementing (Stripe is a great example — never stub Stripe)
- The operational visibility the vendor provides is genuinely valuable to the team
- I'd be on the hook for maintaining something complex and the vendor is cheaper than my time
Closing
The best architecture decision is often the one that fits on a napkin and runs on infrastructure you already pay for. A MySQL table doesn't have a status page, but it also doesn't have a surprise pricing change or a terms-of-service update that breaks your integration. Build boring things first. Upgrade when boring stops working.
Need help shipping something like this? Get in touch.