TL;DR: SQLite on WSL fails silently due to incompatibilities in the file locking mechanism between the Linux kernel and the NTFS filesystem. The fix is to create the
.sqlitefile on the Windows filesystem (/mnt/c/...) and point to it via symlink from your Laravel project.
Laravel has used SQLite by default since version 11 — and for good reason. To spin up an application quickly, validate an idea, or develop a feature without depending on a locally running MySQL or PostgreSQL, SQLite is unbeatable. Zero configuration, a single file, php artisan migrate and you’re done.
But on WSL, this seemingly simple workflow sometimes turns into this:
SQLSTATE[HY000]: General error: 5 database is locked
Or worse — the database appears to work, but writes vanish, migrations hang midway, and the behavior is completely unpredictable from one run to the next. You check the .env, re-read the documentation, restart php artisan serve. Nothing changes.
The problem isn’t your application. It’s where the file lives.
SQLite uses a file locking mechanism to ensure write exclusivity — only one process can write to the database at a time. This mechanism relies on operating system primitives that vary across filesystems.
On WSL2, the /home directory and the entire Linux filesystem live inside a virtual disk (a .vhdx file). When SQLite tries to acquire a lock on this filesystem, it uses POSIX calls (flock, fcntl) that work correctly on native Linux.
The problem shows up in certain concurrent access scenarios — multiple workers, parallel tests, or even the development server itself with hot reload — where WSL2 cannot guarantee the locking semantics that SQLite expects. The result is a lock that never gets released, or a write that silently fails to persist.
WSL2 is remarkably mature, but the translation layer between the Linux kernel and the underlying filesystem remains a source of friction in edge cases. SQLite has no way of knowing it’s operating on top of this layer — it tries to use the OS primitives and assumes they’ll behave predictably. When they don’t, the result is silent corruption or eternal locks.
The workaround is elegant and has zero performance cost for development use:
.sqlite file inside the Windows filesystem (e.g., C:\Users\samuel\databases\)database/ folder of your Laravel projectThis way, Laravel sees the database as if it were a local file, but the actual file lives on NTFS — where SQLite can operate with functional file locking.
Open PowerShell and create a dedicated folder:
mkdir C:\Users\$env:USERNAME\databases
# In the WSL terminal
touch /mnt/c/Users/samuel/databases/meu-projeto.sqlite
The file can be empty — Laravel’s migrations will populate it later.
# Navigate to the project folder
cd /home/samuel/projetos/meu-projeto
# Remove the existing file (if any)
rm database/database.sqlite
# Create the symlink
ln -s /mnt/c/Users/samuel/databases/meu-projeto.sqlite database/database.sqlite
Verify it’s set up correctly:
ls -la database/
# lrwxrwxrwx database.sqlite -> /mnt/c/Users/samuel/databases/meu-projeto.sqlite
.envLaravel will follow the symlink transparently. The default configuration already works:
DB_CONNECTION=sqlite
# DB_DATABASE is automatically resolved to database/database.sqlite
Or, if you prefer an explicit absolute path:
DB_CONNECTION=sqlite
DB_DATABASE=/home/samuel/projetos/meu-projeto/database/database.sqlite
php artisan migrate
When you work with multiple projects simultaneously — which is the most common day-to-day scenario — it’s worth structuring the Windows folder clearly:
C:\Users\samuel\databases\
├── agenda-clinica.sqlite
├── clube-leitura.sqlite
├── controle-frotas.sqlite
├── loja-artesanato.sqlite
└── tests\
├── agenda-clinica-test.sqlite
├── clube-leitura-test.sqlite
└── controle-frotas-test.sqlite
One file per project, with descriptive names. When you need to inspect or back up a specific project’s database, everything is in one place and directly accessible through Windows Explorer — which also makes it easy to open with tools like DB Browser for SQLite or TablePlus without navigating the WSL filesystem.
.gitignore handles the file, but not the symlinkLaravel already ships with database/*.sqlite in .gitignore by default. Even so, make sure the symlink is also covered — in some cases Git may try to version the symbolic link itself:
# Laravel's .gitignore (already included by default, but worth confirming)
/database/*.sqlite
/database/*.sqlite-shm
/database/*.sqlite-wal
If you use a dedicated database for tests (a recommended practice to avoid contaminating development data), repeat the process for the test file:
ln -s /mnt/c/Users/samuel/databases/tests/meu-projeto-test.sqlite database/database-test.sqlite
And in phpunit.xml:
<env name="DB_DATABASE" value="database/database-test.sqlite"/>
For :memory: tests, this problem doesn’t exist — the database lives entirely in RAM and doesn’t depend on disk file locking. But for Laravel feature tests that need to persist data between requests, a physical file is necessary and the symlink does the job.
This setup is strictly for local development. In production — even if you use SQLite — the file should live on the server’s native Linux filesystem, without WSL, without network mounts, without translation layers. The workaround solves a dev environment problem; don’t carry it forward.
This problem came up for me every time I needed to spin up a Laravel application quickly using SQLite to speed up development — which is exactly the use case SQLite is meant for in Laravel. No Docker with MySQL, no configuring credentials, just php artisan migrate and move on.
The symptoms varied by project:
In a scheduling system for a clinic, migrations would occasionally hang mid-execution during the initial setup. The database would end up in an inconsistent state, and I had to delete and recreate the file on every attempt. After the symlink, the process became predictable and repeatable.
In an admin panel for a subscription club, the problem appeared in feature tests: they passed locally on an intermittent basis, but never all at once. The cause was concurrent writes in the tests that WSL’s file locking couldn’t arbitrate correctly. With the database on Windows via symlink, the tests became deterministic.
In both cases, the symptoms were different, but the cause and the solution were the same.
/mnt/c?It’s the obvious question. The short answer: performance.
Running a Laravel project on the Windows filesystem via WSL is significantly slower for I/O operations involving many small files — exactly what composer install, PHP’s autoloader, and php artisan itself do all the time. WSL2’s native Linux filesystem is much faster for this.
The symlink gives you the best of both worlds: the entire project lives on the fast Linux filesystem, and only the SQLite file — which needs reliable NTFS locking — sits on Windows.
Laravel made SQLite the default choice for a reason: it eliminates friction at the start of development. WSL2 exists for the same reason: eliminating friction for developers who work on Windows but need a Linux environment.
When the two collide on this specific point, the workaround isn’t sophisticated. It’s a symlink. But understanding why it works — the difference between the two filesystems and how SQLite depends on OS primitives to function correctly — is what turns a Stack Overflow solution into knowledge you can apply with confidence next time.
If you landed here with database is locked in your terminal at 11 PM trying to spin up a quick project, you can breathe. In two minutes you’ll be back to what matters.
Have another classic dev environment friction you solved in a non-obvious way?