Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Cloudflare D1 Laravel Package

erimeilis/laravel-cloudflare-d1

Adds Cloudflare D1 as a native Laravel database connection, letting your app run queries against D1's serverless SQLite at the edge. Configure credentials, then use Eloquent and the query builder as usual to build edge-friendly apps.

View on GitHub
Deep Wiki
Context7

Getting Started

What you're actually getting. A Laravel database driver, not a database server. erimeilis/laravel-cloudflare-d1 registers d1 as a connection type that speaks to Cloudflare's D1 HTTP API under the hood, so DB::connection('d1'), Eloquent, the query builder, Schema::connection('d1'), and php artisan migrate --database=d1 all work the way you expect — until they don't, because there's no PDO handle anywhere in the stack. That single fact is the lens for everything below.

Install and wire up the minimum.

composer require erimeilis/laravel-cloudflare-d1
// config/database.php
'd1' => [
    'driver'      => 'd1',
    'account_id'  => env('CLOUDFLARE_ACCOUNT_ID'),
    'database_id' => env('D1_DATABASE_ID'),
    'token'       => env('CLOUDFLARE_TOKEN'),
],

Match the array keys against the README exactly — the driver reads those keys, not your env var names, and a typo'd key fails quietly enough to cost you an afternoon. Create a D1 database and an API token scoped to D1 in the Cloudflare dashboard first; both identifiers are visible there.

Where to look, in order:

  1. README.md for the connection keys and the migration-tooling commands.
  2. src/D1Connection.php and the grammar classes — this is where dialect, batching, and transaction semantics live, and you will end up reading them.
  3. tests/ — check whether they hit a real D1 instance or fake the HTTP layer. That determines how much confidence the suite actually buys you.
  4. The migration-tooling entry points, before you point them at anything you care about.

First use case: something read-heavy and boring. An internal dashboard, a reporting endpoint, a scheduled aggregation job, a prototype, a small tenant in a multi-tenant app. Do not start by pointing your transactional core at it.

First-hour smoke test. Run all of this against a scratch database before writing any feature code:

DB::connection('d1')->select('select 1');                        // connectivity
Schema::connection('d1')->create('widgets', fn ($t) => ...);     // DDL path
Widget::create(['name' => 'a']);                                 // Eloquent write
Widget::where('name', 'a')->first();                             // Eloquent read
DB::connection('d1')->table('widgets')->insert($rows);           // batch path
DB::connection('d1')->transaction(fn () => /* ... */);           // transaction semantics

Then deliberately break it twice: revoke the token and see what exception surfaces, and send a malformed statement and see whether the error names the problem or says "database error." Those two answers tell you how much wrapper code you need before you can debug anything in production.

Implementation Patterns

Pilot alongside MySQL, don't swap it. Set protected $connection = 'd1' on the models in scope (a tiny base model keeps it tidy) rather than flipping DB_CONNECTION. Rollback becomes a one-line revert instead of a coordinated env change.

abstract class D1Model extends Model
{
    protected $connection = 'd1';
}

Keep MySQL as the source of truth for the whole pilot. D1 is a second connection, not a replacement, until you've written down the cutover-back procedure.

Treat the migration tooling as a diff generator, not a converter. Run it to produce a candidate schema from your MySQL structure, then hand-audit types, defaults, indexes, foreign keys, JSON columns, enums, generated columns, and collation. Park the output in a D1-specific path so the two schemas can evolve and be diffed independently:

database/migrations/          # MySQL
database/migrations/d1/       # D1

For greenfield work, skip the tooling entirely and hand-write the schema.

Design every non-trivial migration for SQLite's ALTER limits. D1 inherits them. Beyond adding a column you're doing create-new-table → copy → drop → rename, and those migrations must be idempotent because you will re-run them after a failed deploy. Write them as if the deploy dies halfway through.

Batch writes and actually measure the batch. D1's batch endpoint reduces round trips, which is where the bulk-insert gains come from — not from some magic in the driver. Probe your real row shape against the bound-parameter ceiling and the payload size limit, then pick a chunk size with headroom.

// One HTTPS round trip:
Widget::query()->insert($chunkOfRows);

// vs. one HTTPS round trip *per row*:
foreach ($rows as $row) { Widget::create($row); }

Chunk long update/delete statements too — an unbounded UPDATE hits D1's query-duration limits, not just slowness.

Write query code as if each statement costs money, because it does. D1 bills on rows read and written, so ordinary Eloquent hygiene becomes budget hygiene:

  • Eager-load aggressively; an N+1 here is a latency and an invoice problem.
  • ->select(['id', 'name']) on list endpoints, always.
  • Skip deep OFFSET pagination — keyset paginate on an indexed column.
  • Cache reference data in Laravel's cache instead of re-querying every request.

Wrap the HTTP client with retry and instrumentation. The driver sits on Laravel's HTTP client, which is your cheapest extension point. A decorator that retries 429/5xx with jittered backoff, logs statement latency, and counts round trips per request pays for itself in the first week. Add a middleware that warns above a threshold — say 20 queries per request — and it catches regressions that MySQL's latency would have hidden.

Keep transactions short and single-batch. DB::transaction() maps onto D1's batch atomicity, not a PDO transaction. No application logic, no HTTP calls, no loops of independent writes inside the closure. For multi-step workflows that need rollback of side effects outside the database, use an idempotency key and a status column rather than pretending the transaction covers them.

Run the same code path against a real D1 instance in CI. Fake the HTTP layer for fast unit tests, but keep a separate integration suite pointed at an actual D1 database, and gate every Laravel and PHP upgrade on it passing. In-memory SQLite as a stand-in gives false confidence — pragmas, limits, and failure modes all differ.

Sequence the rollout. Spike on your representative models and your ten most expensive queries → benchmark p95 latency and throughput from your actual runtime region → pilot one bounded service behind a flag → gate on written latency, error-rate, and cost thresholds before expanding.

Gotchas and Tips

Every query is an HTTPS round trip. This one fact reshapes your code. A page that fires 40 queries against MySQL will work here and be unusable in production. Profile round trips per request, not just milliseconds.

Transactions are not what you think. No row-level locking, no SELECT ... FOR UPDATE, no long-lived session. Partial batch failures can leave ambiguous commit state, so design writes to be idempotent and retry-safe. Audit every DB::transaction() in anything you port and decide per call site whether it still means anything.

Type affinity will bite you. There are no native DECIMAL, DATETIME, ENUM, or JSON types. Dates as text, booleans as integers, money as integer minor units. Declare $casts on every model and never trust the column type to coerce for you. Collation and LIKE case-sensitivity differ from MySQL — test string matching explicitly. There's no full-text search; plan on LIKE-with-index or an external index.

No persistent connections means worker concurrency is a real risk. Queue workers and Octane change the connection lifecycle. Cap worker concurrency so you don't open a connection storm against the API, and run a deliberate Octane test — statement reuse and connection lifecycle are exactly where a driver like this breaks in ways FPM requests never reveal.

Distinguish failure classes in your handler:

  • 429 — back off and retry with jitter; surface as a soft failure.
  • 5xx / timeout — retry a bounded number of times, then fail loudly.
  • 4xx from the query — a dialect or statement bug; never retry, log the full statement.

Laravel's exception handler will happily collapse all three into "database error." Override it so on-call can tell them apart at 3am.

The Laravel 11 vs 12 grammar-constructor difference is a warning sign, not a footnote. v1.2.1 shipped CI workarounds because the SQLite Grammar parent gained a constructor between majors. Pin the package version and the Laravel minor, and treat every Laravel upgrade as a change that requires the real-D1 integration suite to pass. This is the failure mode that appears silently after an unrelated composer update.

Debugging has no console. You can't psql into it. Use wrangler d1 execute or the Cloudflare dashboard for out-of-band inspection. Lean on dump()/dd() sparingly — each one costs a round trip. Log the rendered SQL plus bindings before execution: dialect mismatches usually return wrong results rather than errors, so the statement is your only evidence.

Foreign keys and pragmas may be per-session. Verify enforcement rather than assuming it. A silently unenforced foreign key found months after a MySQL port is a data-integrity bug, not a bug report.

Migration tooling is a starting point. It will miss your JSON columns, enums, generated columns, and collation differences on the first pass. Budget manual diffing time, and run it twice to confirm idempotency before you trust it in CI.

Extension points, in order of usefulness: the decorated HTTP client (retry, timing, logging), a custom base model, $casts, the connection class itself if you need different auth or read-replica routing, and the grammar as a last resort for dialect bugs. The MIT license means forking is viable — with 14 stars and a single maintainer, vendor the package or keep a private fork from day one and track upstream commits.

Have a rollback that isn't a plan. During any pilot, MySQL stays authoritative. Export D1 before every migration (Time Travel or a dump), and write the cutover-back procedure down before you need it.

Debugging checklist when something "just doesn't work":

  1. Is the connection key spelled exactly as the README says?
  2. Does select 1 succeed on that connection in isolation?
  3. Is the failure 4xx (your statement) or 5xx/429 (the platform)?
  4. Log the rendered SQL and bindings — does it look like valid SQLite?
  5. Check tests/ for whether the maintainer's suite covers the feature you're using.
  6. Run the same statement through wrangler d1 execute to separate driver bugs from dialect bugs.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource