SQLite as Your Laravel SaaS DB: Where It Holds Up and Where It Breaks
I ran SQLite as the primary database on a real single-tenant Laravel app. Here's what actually happened when the load got serious.
SQLite in production gets dismissed immediately by most people who've never actually tried it for a real workload. I was one of those people — until a client situation forced my hand, and it worked better than I expected for longer than I expected. Then it didn't. Here's the honest version.
The Setup That Got Me Here
I had a client — a small industrial equipment distributor in the Pacific Northwest — who needed a quoting and order management tool. Single tenant, meaning one company, one database, maybe 15 internal users on it at any given time. The data model wasn't trivial: products, configurations, quotes, line items, vendor pricing, customer history. Maybe 40 tables.
Their hosting situation was annoying. They wanted managed hosting on a VPS they already controlled, the ops overhead had to be near zero, and they had a hard budget. Standing up RDS or a managed Postgres instance wasn't a deal-killer on cost, but the "zero ops" constraint was real — nobody on their side was going to babysit a database service, and I wasn't going to do it for free.
SQLite as the primary store was the right call for that context. I want to be specific about that context, because it matters a lot.
What SQLite Actually Gives You in a Laravel App
Laravel has had first-class SQLite support forever. The database.php config takes about 30 seconds to wire up:
// config/database.php
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DATABASE_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
],
That foreign_key_constraints line is not optional — SQLite doesn't enforce foreign keys by default, and if you don't flip that on, you'll have orphaned rows you won't find until something breaks in production in a confusing way. Turn it on.
Migrations run. Eloquent works. Query Builder works. Tinker works. For the 80% of what a Laravel app does, you will not notice you're not on Postgres or MySQL.
Deployment becomes almost insultingly simple. The database is a file. Backups are cp. Zero-downtime deploys on a single server with no database migration coordination. For a 15-user internal tool, that's not a small thing.
The WAL Mode Config You Must Not Skip
SQLite's default journal mode serializes all writes behind a single writer lock. That means concurrent reads block during writes. For anything more than a toy, you want WAL (Write-Ahead Logging) mode enabled.
I set this in a service provider, early in the boot cycle:
// app/Providers/AppServiceProvider.php
public function boot(): void
{
if (config('database.default') === 'sqlite') {
DB::statement('PRAGMA journal_mode=WAL;');
DB::statement('PRAGMA synchronous=NORMAL;');
DB::statement('PRAGMA busy_timeout=5000;');
DB::statement('PRAGMA cache_size=-64000;'); // ~64MB page cache
DB::statement('PRAGMA foreign_keys=ON;');
DB::statement('PRAGMA temp_store=MEMORY;');
}
}
WAL mode lets multiple readers run concurrently with a single writer, instead of blocking everyone. busy_timeout is critical — without it, concurrent write attempts return SQLITE_BUSY immediately, and you get database locked exceptions in your logs. Setting it to 5000ms tells SQLite to retry for up to 5 seconds before failing. For a 15-person internal app, that's enough headroom.
synchronous=NORMAL is a reasonable trade-off. FULL is safer but slower; OFF is fast but you can lose data on a power failure. NORMAL is fine for most non-financial workloads on a well-maintained VPS.
Without these PRAGMAs, I'd have told you SQLite in production is hopeless. With them, it genuinely behaves.
Where It Actually Held Up
That industrial app ran on SQLite for about 14 months. Typical concurrent users: 8-12. Heaviest queries were quote summaries joining products, configurations, and vendor pricing — 5-6 table joins, nothing exotic. The database file grew to about 800MB over that period.
Query performance was fast. Faster than I expected, honestly. SQLite keeps the whole thing memory-mappable, and with a decent page cache, repeated reads on a warm instance are nearly instant. The quote summary report that I was most worried about came in under 80ms consistently.
Migrations were the most pleasant I've dealt with on any project. php artisan migrate on a file. Done. No connection pooling to think about, no migration locks to worry about, no ALTER TABLE timeouts on a loaded database server.
Backups: I had a cron job that ran cp on the database file to S3 via rclone every 15 minutes. That's it. It worked every time.
The Gotchas That Will Bite You
Schema migrations with ALTER TABLE. SQLite's ALTER TABLE is crippled compared to Postgres or MySQL. You can add a column and rename a column (since 3.25.0). You cannot drop a column natively until SQLite 3.35.0. Laravel's migration system works around this by recreating the table, but that operation requires an exclusive lock for the duration, and on a large table it's not instant. On a 5M-row table, I wouldn't want to find out.
Check your SQLite version on your server. Ubuntu 20.04 ships with 3.31.1. Ubuntu 22.04 ships with 3.37.2. These matter. Some PRAGMA behaviors and column operations differ.
php -r "echo SQLite3::version()['versionString'];"
Run that before you go to production. Know what you're on.
No connection pooling. This isn't a problem with 15 users. It becomes a problem if you're running a queue worker farm or have background jobs hammering the database. Each PHP-FPM process opens its own connection to the file. Under heavy queue load, I've seen database is locked errors even with busy_timeout set, because the write contention overwhelms the timeout window. If your queue workers are doing any meaningful write volume, this is where SQLite starts showing its limits.
JSON column support is partial. Laravel's JSON column type maps to TEXT in SQLite. You can store JSON fine, but ->whereJsonContains() and some JSON path operations aren't supported. If your data model leans on JSON columns for querying, Postgres is the right tool.
No native UUID primary keys with auto-increment behavior. Minor, but: if you're using UUID primary keys (which I do by default now for any externally exposed resource), SQLite stores them as TEXT. Fine for correctness, but index performance on TEXT UUID primary keys is worse than on integer sequences or Postgres's native UUID type. For a small dataset it doesn't matter. Watch it on large tables with heavy join loads.
Fulltext search is basic. SQLite has FTS5, which works, but it's not Postgres's tsvector and it's not Elasticsearch. If your users need real search — fuzzy matching, relevance ranking, multilingual tokenization — SQLite FTS5 will disappoint you and you'll end up bolting on something else anyway.
The Exact Moment You've Outgrown It
Here's what I'd watch for, in order:
-
Your queue workers start logging
database is lockederrors under normal load. This is the first real sign. You can tunebusy_timeouthigher, but you're patching a concurrency model that isn't designed for this. -
You need to run schema migrations on a database over ~2GB without a maintenance window. The table-recreation approach for unsupported ALTER TABLE operations will lock out your users.
-
You're scaling to multiple app servers. SQLite is a file. You cannot share it across servers without something like Litestream + a replicated block device, and at that point you're building infrastructure that costs more complexity than just running Postgres.
-
You need point-in-time recovery, replication, or read replicas. These are not SQLite problems to solve. Litestream is interesting and worth knowing about for single-server setups, but it's not a substitute for a proper database service when you actually need those features.
For the industrial client, the signal was #3 — they eventually wanted a mobile field app that needed its own API server, which meant the single VPS model broke down. We migrated to Postgres. The migration itself was straightforward using artisan db:seed with a fresh export, but I'll be honest: the schema had drifted in small ways that required cleanup. Column types that SQLite was silently coercing (everything is flexible text/numeric in SQLite's type affinity system) needed explicit casting.
Lesson there: document your schema assumptions early and don't rely on SQLite's type flexibility as a feature.
When I'd Reach for This
I'd use SQLite as a primary database in Laravel for:
- Single-tenant internal tools with under ~50 concurrent users
- Prototype / MVP builds where proving the product matters more than infrastructure correctness
- Client apps where a single VPS with near-zero ops is genuinely the right constraint
- Per-tenant database-per-tenant architectures (SQLite is actually excellent here — each tenant gets their own file, isolation is trivial, backups are per-file)
I would not use it for:
- Any multi-server deployment
- Apps with heavy background job write volume
- Anything requiring true concurrent write throughput
- Multi-tenant single-database architectures
- Anything in healthcare or finance where point-in-time recovery is a compliance requirement
Closing
SQLite in production isn't a meme or a toy choice — for the right workload, it's genuinely the correct tool, and the Laravel ecosystem supports it well enough that you won't feel like you're fighting the framework. The mistake is treating it as a free upgrade path to MySQL-level concurrency by just flipping a config; it isn't. Know what you're buying, set the PRAGMAs, and watch your queue write volume. You'll get more mileage out of it than you expect, right up until you don't.
Need help shipping something like this? Get in touch.