WordPress on SQLite — on a local file, on Turso, or on Cloudflare D1 over the wire. One plugin, one wp-content/db.php drop-in; the engine is chosen by DB_ENGINE.
It bundles the SQLite Database Integration driver (the MySQL-on-SQLite translation layer WordPress Playground runs on), pinned to a release tag as a git submodule, and adds the two network backends on top of it. The driver's own local-SQLite path is used byte for byte; the remote paths replace only the connection.
| Engine | DB_ENGINE |
Where the data lives | Best for |
|---|---|---|---|
| Local SQLite | sqlite (default) |
wp-content/database/.ht.sqlite |
single-host sites, Playground |
| Turso | turso |
a Turso / libSQL primary, reads optionally served from a published local snapshot | multi-region front ends with a single writable primary |
| Cloudflare D1 | d1 |
a D1 database behind the bundled proxy worker | WordPress in Cloudflare Containers |
- PHP 8.5+ with
pdoandpdo_sqlite(curlfor the remote engines) - WordPress 6.6+
- For development: Nix (everything else comes from the flake)
- Download
wordpress-sqlite-anywhere.zipfrom the latest GitHub Release - In WordPress, go to Plugins → Add New → Upload Plugin and upload the zip
- Activate it. Activation writes
wp-content/db.phpfrom the plugin'sdb.copy; the plugin's admin page (Settings → SQLite integration) shows the drop-in's state and refreshes it when the plugin updates.
Updates are delivered straight from GitHub Releases through plugin-update-checker — no wordpress.org listing.
git clone --recurse-submodules https://github.com/Avunu/wordpress-sqlite-anywhere
cd wordpress-sqlite-anywhere
nix develop # or `direnv allow`
composer install
composer assemble # upstream driver + patches + overlays → build/nix build produces the installable plugin under result/share/wordpress/plugins/wordpress-sqlite-anywhere; nix build .#zip produces the release zip.
Everything is read from wp-config.php constants first and environment variables second, so a container can be configured entirely from its environment.
define('DB_ENGINE', 'sqlite'); // "sqlite" (default), "turso", "d1"; anything else leaves WordPress on MySQLWithout DB_ENGINE, a configured WP_TURSO_URL selects Turso and a configured WP_D1_PROXY_URL selects D1.
define('DB_DIR', '/path/to/database/'); // default: wp-content/database/
define('DB_FILE', '.ht.sqlite');define('WP_TURSO_URL', 'libsql://db-org.turso.io'); // required; "turso://" and "https://" are accepted
define('WP_TURSO_TOKEN', '...'); // bearer token, if the server needs one
define('WP_TURSO_SNAPSHOT', '/var/lib/wordpress/snapshot.db');
define('WP_TURSO_SNAPSHOT_JOURNAL', 'DELETE');
define('WP_TURSO_SCHEMA_CACHE', true);
define('WP_TURSO_HTTP_TIMEOUT_MS', 30000);
define('WP_TURSO_PIPELINE_PATH', '/v2/pipeline');
define('WP_TURSO_TRANSACTION_FALLBACK', 'warn'); // "warn", "error" or "ignore"define('WP_TURSO_REPLICA', '/var/lib/wordpress/turso/replica.db'); // embedded replica; needs the wp_turso extension
define('WP_TURSO_REPLICA_PULL_MS', 1000);
define('WP_TURSO_REPLICA_OPEN_TIMEOUT_MS', 60000);Three shapes, chosen by what is configured:
- Neither
WP_TURSO_REPLICAnorWP_TURSO_SNAPSHOT: every statement goes to the primary over Turso's SQL-over-HTTP API. Correct anywhere; fast only when the primary is co-located, because every statement is a round trip. WP_TURSO_REPLICA: thewp_tursoPHP extension holds an embedded replica open in the PHP process and pulls the primary's changes into it everyWP_TURSO_REPLICA_PULL_MS. Reads come from the replica at local-SQLite speed; writes go to the primary, and the replica pulls before the next read that follows a write, so a request reads its own writes while still reading locally — one pull costs about one primary round trip, where latching the rest of the request to the primary would cost one per statement (a plugin that writes a visitor session on every page view would otherwise send the whole page over the WAN; on a real site that was 10 s against 2.5 s). A request that wrote also pulls the replica up to date before it ends, so the next request — the redirect after a save, the dashboard after a login — finds what was written. Transactions still latch to the primary. No publisher, no snapshot copy, and it works for wp-admin too: on a WAN primary the dashboard went from 1.5 s to 40 ms. One process owns the replica file (an exclusive lock), so this is for FrankenPHP's one-process-many-threads model, not a pool of PHP-FPM workers; a fleet of front ends behind one balancer each keep their own replica.WP_TURSO_SNAPSHOT: reads come from a standalone SQLite file thatturso-snapshot-publisherpublishes (pull → VACUUM INTO → rename) from a private replica, writes go to the primary with the same latch. The pure-PHP option where the extension cannot be loaded; the front end is stale by at most one publish interval and the admin plane must stay on the primary.
Either way PHP never reads a live Turso replica through pdo_sqlite: Turso holds an exclusive lock on it and coordinates its WAL through a file SQLite does not know about. The extension reads it from inside the process that owns it; the publisher hands over a copy.
With the extension loaded, requests to the primary also go through its pooled HTTP client instead of cURL, which keeps connections (and their TLS handshakes) alive across PHP requests — on a WAN primary that is worth ~150 ms per page.
define('WP_D1_PROXY_URL', 'http://d1.internal'); // required: the D1 proxy worker
define('WP_D1_PROXY_TOKEN', '...'); // bearer token of a standalone proxy
define('WP_D1_SCHEMA_CACHE', true);
define('WP_D1_HTTP_TIMEOUT_MS', 30000);
define('WP_D1_TRANSACTION_FALLBACK', 'warn');D1 is reached through d1-proxy-worker, either as the outbound handler of a Cloudflare Container or as a standalone Worker. An optional native transport, php-ext-wp-d1-client, keeps an HTTP connection pool across PHP requests.
Neither D1 nor Turso over HTTP can hold a transaction open across statements. The driver batches what it can (schema changes, multi-statement writes) and otherwise treats BEGIN/COMMIT according to WP_*_TRANSACTION_FALLBACK: warn (log and continue), error (throw), or ignore.
upstream/ git submodule: WordPress/sqlite-database-integration @ a release tag
patches/ numbered patches to upstream's driver (the extension points the remote backends need)
driver/ our additive driver code, overlaid onto upstream's driver source
driver-tests/ our additive driver tests + tooling, overlaid onto upstream's test tree
plugin/ our additions to upstream's plugin directory
src/ SqliteAnywhere\ — the drop-in's engine selection (house style, PHPStan level 8)
packages/ php-ext-wp-turso (Rust: pooled client + embedded replica), turso-snapshot-publisher (Rust),
d1-proxy-worker (TypeScript), php-ext-wp-d1-client (Rust)
bin/assemble.sh upstream + patches + overlays → build/ (no git, no PHP: it runs inside the Nix sandbox)
Two code styles live here on purpose. src/, the main plugin file and tests/ are house style (strict_types, final, PSR-12, camelCase). driver/, driver-tests/ and plugin/ implement upstream's interfaces in upstream's namespace-less world, so they follow the WordPress coding standard upstream uses — with complete types and PHPStan level 8 all the same. Upstream's own code is never linted or typed here, only patched minimally.
upstream/ is pinned to a tag, never a branch. The upstream-bump workflow checks weekly for a newer release, moves the pin, assembles, and opens a feat(driver): bump bundled driver to vX.Y.Z PR when the patch series still applies — or an issue with the rejected hunks when it does not. To resolve one by hand:
bin/patches-checkout.sh # `patched` branch in upstream/ with the series applied via git am --3way
# resolve conflicts, commit
bin/patches-export.sh # regenerate patches/*.patch, detach upstream/ back to the tagnix develop # PHP 8.5, composer, phpstan, phpcs, cargo, node, tursodb
composer assemble # build/ — the patched, overlaid driver and plugin
composer phpstan # level 8 over src/, driver/, plugin/, tests/
composer phpcs # PSR-12 over the house-style paths
driver-tests/tools-project/vendor/bin/phpcs --standard=phpcs-driver.xml.dist # WPCS over driver/
composer test # the plugin's own suite (PHPUnit 12)
# the bundled driver's suites (PHPUnit 9, upstream's), against each backend
cd build/packages/mysql-on-sqlite
../../../driver-tests/tools-project/vendor/bin/phpunit --testsuite pdo
WP_SQLITE_TEST_BACKEND=d1 ../../../driver-tests/tools-project/vendor/bin/phpunit --testsuite remote
WP_SQLITE_TEST_BACKEND=turso ../../../driver-tests/tools-project/vendor/bin/phpunit --testsuite remoteEvery check also exists as a flake check, which is what CI runs: nix build .#checks.x86_64-linux.<name> -L for assembled, plugin-header, phpstan, phpcs, phpcs-driver, phpunit, driver-pdo, driver-d1, driver-turso, turso-embedded (the extension against a local tursodb --sync-server), publisher, d1-client and pre-commit; the D1 proxy worker's vitest suite runs on a plain Node runner (its pool needs workerd). nix flake check runs the sandbox-safe subset; the pre-push git hooks run PHPStan, PHPCS and the Nix linters locally — push from nix develop so they find their tools.
main requires every one of those jobs to pass; Dependabot PRs auto-merge once they do. Majors of the driver suites' PHPUnit and of the worker's vitest stack are held back in .github/dependabot.yml (upstream's suites use PHPUnit 8/9 APIs; the worker config predates the Vitest 4 plugin form).
Releases are fully automated from Conventional Commits via Release Please. There is no manual version bump — just write conventional commit messages:
fix: ...→ patch release (0.0.x)feat: ...→ minor release (0.x.0)feat!: ...or aBREAKING CHANGE:footer → major release (x.0.0)chore: ...,docs: ...,refactor: ...→ no release on their own
On every push to main, the Release workflow opens (or updates) a release PR that accumulates the pending changes and previews the next version + changelog. Merging that PR:
- bumps the version in
composer.json(and thewordpress-sqlite-anywhere.phpheader) and updatesCHANGELOG.md; - creates the git tag and a GitHub Release with notes generated from the commits;
- builds the plugin on a Nix runner (
nix build .#zip) and attacheswordpress-sqlite-anywhere.zip(withvendor/and the assembled driver bundled) as the release asset.
Client sites then pick up the new version automatically via plugin-update-checker.
The version in the wordpress-sqlite-anywhere.php plugin header is stamped from composer.json at build time, so composer.json is the single source of truth; the plugin-header check keeps the committed header in step, because plugin-update-checker reads it from the tag. The bundled driver's own SQLITE_DRIVER_VERSION is upstream's and is never changed here.
Repo setting: Settings → Actions → General → Workflow permissions must allow "Read and write permissions" and "Allow GitHub Actions to create and approve pull requests" so Release Please can open the release PR.
GPL-2.0-or-later, inherited from the bundled SQLite Database Integration driver.