fwolfsjaeger/doctrine-cockroachdb-bundle
Symfony bundle that adds CockroachDB support to Doctrine, providing DBAL configuration, platform integration, and service wiring so your Symfony app can use CockroachDB as a Doctrine-managed database.
1. Install and register
composer require fwolfsjaeger/doctrine-cockroachdb-bundle
There is likely no Symfony Flex recipe for a 2-star package, so expect to add it manually to config/bundles.php:
return [
// ...
Fwolfsjaeger\DoctrineCockroachDBBundle\DoctrineCockroachDBBundle::class => ['all' => true],
];
Open the bundle class first — it is small, and the only thing that matters is which DBAL Driver class it registers and under what URL scheme. That tells you everything else.
2. Point Doctrine at CockroachDB
The bundle exists to give Doctrine a driver that speaks CockroachDB's dialect on top of the PostgreSQL wire protocol. In .env.local:
DATABASE_URL="cockroachdb://[email protected]:26257/app?sslmode=disable"
If the scheme isn't picked up (or you prefer to be explicit), set the driver class on the connection in config/packages/doctrine.yaml:
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
driver_class: '%env(COCKROACH_DRIVER_CLASS)%' # the class from the bundle
server_version: '23.1' # pin it; avoids probing
3. Prove it works against a real node
Do not start with SQLite. Use a single-node cluster:
docker run -d --name crdb -p 26257:26257 -p 8080:8080 \
cockroachdb/cockroach:latest start-single-node --insecure
Then the normal Doctrine loop:
bin/console doctrine:database:create
bin/console make:entity # or your existing entities
bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate
First use case: take one low-risk bounded context (a new module, a side service) and run its migrations + CRUD + one write transaction. That is the fastest way to find out whether the driver's retry and introspection behaviour actually holds for your schema.
Register the driver once, reuse everywhere. The bundle's value is a single driver class. Use it for every connection (default, read, reporting) rather than mixing in postgresql:// for some — mixed drivers produce inconsistent schema diffs.
Wrap every write transaction in a retry loop. This is the single most important pattern with CockroachDB. Contention surfaces as SQLSTATE 40001 (restart transaction), and the whole transaction must be replayed — DBAL does not do this for you, and Connection::transactional() will not retry.
final class Retry
{
public function __construct(private int $attempts = 5) {}
public function run(callable $fn, Connection $conn): mixed
{
for ($i = 1; ; $i++) {
try {
return $conn->transactional($fn);
} catch (\Throwable $e) {
if ($i >= $this->attempts || !$this->isRetryable($e)) {
throw $e;
}
usleep((int) (10_000 * 2 ** $i)); // jittered backoff
}
}
}
private function isRetryable(\Throwable $e): bool
{
$sqlState = $e->getPrevious()?->getCode() ?? $e->getCode();
// 40001 = serialization failure, 40003 = connection reset
return in_array((string) $sqlState, ['40001', '40003'], true);
}
}
Make this a decorator around the repository/service, not something sprinkled inside entities.
Prefer application-generated primary keys. CockroachDB has no sequences. If you rely on @GeneratedValue(strategy="IDENTITY") with SERIAL, you will get either an error or a slow unique_rowid() fallback depending on how the driver maps it. Use UUIDs:
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
private Uuid $id;
public function __construct() { $this->id = Uuid::v7(); }
Uuid::v7() (time-ordered) keeps index locality much better than v4 — worth the one-line change.
Keep transactions short and touch rows in a deterministic order. CockroachDB is optimistic by default. Long transactions and cross-table writes in arbitrary order are the two things that generate the most 40001s. Sort the IDs you update before looping.
Migrations: review the SQL, don't trust the diff. Run doctrine:migrations:diff --formatted and read the generated DDL before committing. CockroachDB supports most ALTER TABLE forms but not all, and its introspection surfaces types differently from PostgreSQL, so the schema manager may oscillate between two "correct" states. If that happens, hand-write the migration with $this->addSql() and add a doctrine:schema:validate allowance in CI.
Raw SQL for CockroachDB-only features. Follower reads, AS OF SYSTEM TIME, UNIQUE ROW region hints — use Connection::executeQuery() directly. They don't belong in the ORM, but they're often the reason you chose CockroachDB in the first place.
Test against a real node in CI. Testcontainers or a service container with start-single-node --insecure, then run migrations and a 40001-generating concurrent test. Without this, the retry path is untested.
composer.json, read the driver source before adopting, and assume you may need to fork it. The surface is probably small enough that forking is cheap.driver_class vs url scheme. If Doctrine falls back to the stock pdo_pgsql driver, you'll get no CockroachDB-specific handling — and no error, just subtly wrong behaviour. Verify with bin/console doctrine:query:sql 'SELECT version()' and by dumping get_class($conn->getDriver()) in a tinker session.$e->getCode() frequently returns 0 while the pgcode lives on $e->getPrevious()->getCode(). Log both when you first integrate, then codify the check.migrations:diff generating phantom changes (default values, int8/bigint, timestamptz precision). Mitigate by disabling doctrine.dbal.schema_filter for CockroachDB's internal schemas and pinning server_version so the platform doesn't probe and mis-detect.sslmode=require is not enough; verify-full with sslrootcert is expected. Pass these via options: {sslrootcert: ...} in doctrine.yaml, not the URL, if the URL parser drops them.pg_advisory_lock), LISTEN/NOTIFY, and some SET statements are unsupported or partially supported. If a third-party bundle uses them (tenant switchers, queue packages), it will fail at runtime, not at boot.doctrine.dbal.logging: true in dev. When a transaction fails, print SHOW TRANSACTION STATUS — it's the fastest way to distinguish a retryable conflict from an aborted session.Doctrine\DBAL\Logging\Middleware for query telemetry, (3) subclass the bundle's driver class to add behaviour, (4) fork the driver. Steps 1–3 keep you off the fork path.doctrine:schema:update --force. Against a distributed database with online schema changes, it's a footgun. Migrations only, always reviewed.How can I help you explore Laravel packages today?