Skip to content

[13.x] Keep Eloquent on the direct connection during migrations - #61092

Open
RobertoNegro wants to merge 1 commit into
laravel:13.xfrom
RobertoNegro:fix-eloquent-direct-connection-binding
Open

[13.x] Keep Eloquent on the direct connection during migrations#61092
RobertoNegro wants to merge 1 commit into
laravel:13.xfrom
RobertoNegro:fix-eloquent-direct-connection-binding

Conversation

@RobertoNegro

@RobertoNegro RobertoNegro commented Aug 7, 2026

Copy link
Copy Markdown

The transaction-pooler support added in #60425 routes migrations to the ::direct connection, and Migrator::runMethod() sets the default connection accordingly:

// Migrations/Migrator.php:515
$this->resolver->setDefaultConnection($connection->getNameWithReadWriteType()); // "pgsql::direct"

Builder::newModelInstance() then throws that away:

// Eloquent/Builder.php:1793
$this->query->getConnection()->getName()   // "pgsql"

getName() returns getConfig('name'), and newModelInstance() is the funnel for most models produced by a builder, hydrate() included. So a model created in a migration is written to the pooled connection, outside the migration's transaction, and a model read on the direct connection comes back bound to "pgsql", taking any later save() or delete() with it. Statements that never build a model (mass update(), delete(), upsert()) stay on the direct connection.

On a fresh database this fails outright (relation "..." does not exist, since the table was created inside the direct connection's transaction). On an existing one it succeeds silently and is no longer rolled back with the DDL.

Dropping the suffix is right for ::read / ::write, where a model read from a replica must stay writable. ::direct serves both reads and writes, so there it changes which server the statement reaches.

This adds Connection::getNameWithDirectType(), which keeps the suffix only for the direct variant, and uses it wherever Eloquent binds a model to a resolved connection name: Builder::newModelInstance(), Factory::store() (reachable through migrate --seed, which MigrateCommand runs inside usingConnection()), the new-model fallback in Model::save(), and MorphTo::createModelByType(). The latter two cover models created outside an Eloquent builder, such as relation-created and polymorphic models.

Reproduction

With a direct endpoint configured, run a migration that creates a table and then writes to it through Eloquent. Either form lands on the pooled connection:

Schema::create('examples', function (Blueprint $table) {
    $table->id();
});

Example::create([]);                       // binds the new model to "pgsql"
Example::on('pgsql::direct')->create([]);  // also "pgsql", newModelInstance() re-pins it

migrate:fresh fails with relation "examples" does not exist. A pooler is not needed to see it: pointing direct at the same host as the pooled connection is enough, since the two are separate Connection instances with separate transactions.

Backward compatibility

getNameWithDirectType() returns the same value as getName() unless the connection is the direct variant. Read/write routing is untouched and covered by a test, and getName() and getNameWithReadWriteType() are unchanged.

Tests

Unit tests cover both halves of the new method: that it keeps the ::direct suffix for the direct variant, and that null, read and write still collapse to the base name, so read/write routing cannot regress. Two more assert that newModelInstance() binds a model to whichever of the two the connection reports.

The regression itself is covered by an integration test, added to the file #60425 introduced. It creates a table inside a transaction on the direct connection, then writes to it and reads it back through a model, so anything falling back to the pooled connection cannot see it. That test case already points direct at the same host as the pooled connection, so CI needs no extra service. Without the patch it fails with relation "pooled_direct_models" does not exist (Connection: pgsql, ...).

Found while moving a production application onto this configuration, and verified there against PostgreSQL 18 behind PgBouncer 1.25.2 in pool_mode = transaction.

@RobertoNegro
RobertoNegro force-pushed the fix-eloquent-direct-connection-binding branch 2 times, most recently from 37ded63 to f0aa468 Compare August 7, 2026 15:07
Builder::newModelInstance() re-pinned every model it built with Connection::getName(), which returns the base config name and so discarded the ::direct routing. Model writes inside a migration therefore ran on the pooled connection, outside the migration's transaction.

Adds Connection::getNameWithDirectType(), which keeps the suffix for the direct variant and collapses read/write to the base name as before, and uses it everywhere a model is bound to its connection's name: Builder::newModelInstance(), Factory::store(), the fresh model fallback in Model::save(), and MorphTo::createModelByType().
@RobertoNegro
RobertoNegro force-pushed the fix-eloquent-direct-connection-binding branch from f0aa468 to 806b9be Compare August 7, 2026 15:33
@shaedrich

Copy link
Copy Markdown
Contributor

This adds Connection::getNameWithDirectType()

This is PostgreSQL-specific naming, this shouldn't bleed into Laravel core functionality.

@RobertoNegro

Copy link
Copy Markdown
Author

::direct is the connection-routing type introduced in #60425 alongside ::read and ::write.. PostgreSQL is currently the only driver that configures a direct PDO, but the model has to retain the Laravel connection name in order to resolve the same Connection instance and transaction.

@shaedrich

Copy link
Copy Markdown
Contributor

Then nevermind—::direct just sounds so non-intuitive. I would have expected something like ::readwrite

@RobertoNegro

Copy link
Copy Markdown
Author

Then nevermind—::direct just sounds so non-intuitive. I would have expected something like ::readwrite

It refers to the configured direct endpoint: a connection that reaches the database directly and bypasses the transaction pooler.

I do not think ::readwrite would describe it accurately, the regular connection already supports both reads and writes, and ::read / ::write describe PDO routing. ::direct instead distinguishes the target endpoint and transaction context: pooled vs direct.

The direct configuration and ::direct routing name were introduced in 13.x by #60425. This PR only fixes Eloquent dropping that existing routing name while binding models.

Renaming or revisiting the routing terminology would be a separate API and backward-compatibility discussion.

@shaedrich

Copy link
Copy Markdown
Contributor

Ah, thanks for the explanation 👍🏻

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants