mpyw/laravel-database-mock
Experimental Laravel package that mocks underlying PDO connections with Mockery, letting you stub SELECT, INSERT, and UPDATE queries and results for fast, database-free tests. Requires PHP 8.2+ and Laravel 11+.
Install it as a dev dependency and pin the alpha explicitly:
composer require mpyw/laravel-database-mock:VERSION@alpha --dev
Confirm the baseline before writing tests: PHP ^8.2, Laravel ^11–^14, Mockery already in require-dev (^1.6.12), and acceptance of the transitive alpha mpyw/mockery-pdo. There is no service provider, no config file, no publish step — if you are registering anything, you have misread the setup.
Where to look first, in order:
SELECT example — it is the entire mental model in ten lines: declare SQL, declare rows, call the model.mpyw/mockery-pdo — this is where the expressive API actually lives (shouldSelect, shouldFetchAllReturns, shouldInsert, shouldUpdateOne, shouldUpdateMany, shouldDelete). laravel-database-mock only wires that PDO double into Laravel's Connection.DBMock::mockEachPdo() with reader() / writer() — the read/write replica variant, and the one capability that justifies the alpha dependency.First use case — spike one query shape, not the whole suite:
$pdo = DBMock::mockPdo();
$pdo->shouldSelect('select * from `users`')
->shouldFetchAllReturns([[
'id' => 1, 'name' => 'John', 'email' => '[email protected]',
'created_at' => '2020-01-01 00:00:00', 'updated_at' => '2020-01-01 00:00:00',
]]);
$this->assertSame('John', User::first()->name);
Run this in a single throwaway test class against a repository or query object. Do not wire it into a base test case or setUp() until the spike has proven itself — a 0.0.1-alpha package should earn its place in your harness.
Mock per test, per connection, at the call site. Put DBMock::mockPdo() at the top of the test body. The double replaces the PDO beneath the connection for that test only, keeping the substitution visible and preventing leakage into tests that should remain honest.
Script the conversation — do not stub a single call. The mock is expectation-based and ordered. Every query the code under test will issue must be declared, in sequence:
Carbon::setTestNow('2020-01-02 00:00:00');
$pdo = DBMock::mockPdo();
$pdo->shouldSelect('select * from `users` where `email` = ? limit 1', ['[email protected]'])
->shouldFetchAllReturns([[
'id' => 2, 'name' => 'John', 'email' => '[email protected]',
'created_at' => '2020-01-01 00:00:00', 'updated_at' => '2020-01-01 00:00:00',
]]);
$pdo->shouldUpdateOne(
'update `users` set `email` = ?, `users`.`updated_at` = ? where `id` = ?',
['[email protected]', '2020-01-02 00:00:00', 2]
);
$user = User::query()->where('email', '[email protected]')->first();
$user->forceFill(['email' => '[email protected]'])->save();
Inserts need two expectations. shouldInsert() asserts the statement; the returned ID is a separate Mockery expectation because Laravel calls lastInsertId() afterwards:
$pdo->shouldInsert(
'insert into `users` (`name`, `email`, `updated_at`, `created_at`) values (?, ?, ?, ?)',
['John', '[email protected]', '2020-01-01 00:00:00', '2020-01-01 00:00:00']
);
$pdo->expects('lastInsertId')->andReturn(2);
Freeze time on every write path. Carbon::setTestNow() is effectively mandatory — timestamps are baked into both the SQL bindings and the hydrated attributes. A drifting clock produces failures that read like package bugs but are pure clock drift.
Assert hydration, not just SQL. Because the seam sits below the ORM, Eloquent still hydrates, casts, appends accessors, and wraps collections. Asserting on ->toArray() covers model behavior alongside the query assertion — that fidelity is the main advantage over Model::shouldReceive().
Replica routing — the differentiator. When the connection is configured with separate read/write hosts, use mockEachPdo() and assert that writes land on writer() and reads on reader():
$pdos = DBMock::mockEachPdo();
$pdos->reader()
->shouldSelect('select * from `users` where `email` = ? limit 1', ['[email protected]'])
->shouldFetchAllReturns([[...]]);
$pdos->writer()
->shouldUpdateOne('update `users` set `email` = ?, `users`.`updated_at` = ? where `id` = ?', [...]);
This is a bug class a single in-memory SQLite database structurally cannot catch.
Failure-path tests. Leave an expectation unmet, or use andThrow() on the PDO double, to exercise retry/rollback branches without a database. Handle DB::transaction() deliberately — see gotchas.
Where this belongs in your suite. Carve out a small "SQL contract" tier: repositories, reporting/billing query builders, replica routing, connection-failure paths. Keep RefreshDatabase/SQLite for everything else. A tests/SqlContract/ directory with the convention documented beside it is the durable shape.
sqlite or pgsql the identifiers quote differently ("users") and every assertion fails. Match your test connection's configured driver to the strings you write.true vs 1). When a binding assertion fails, dump both arrays — the diff is usually a datetime format or a boolean cast.lastInsertId() is a separate expectation. Forget it and you get a null ID plus a confusing model assertion failure far from the real cause.exists() will fail the test. Eager-load deliberately and script any extra query.shouldSelect returning the row you "wrote" — you are scripting a conversation, not simulating a database.DB::transaction() issues beginTransaction/commit/rollBack on the handle. Script or relax those expectations explicitly; rollback-on-exception tests need extra care.Mockery::close() to tearDown() if you don't already have it — unmet expectations surface there, and the message won't mention this package.mpyw/mockery-pdo can move. Run these tests in CI against the oldest and newest supported Laravel versions.fake()/expect(). Fine in a dedicated directory; don't mix styles in one file.DBMock in your own base helper — which also gives you one place to change when the alpha API shifts.How can I help you explore Laravel packages today?