laminas/laminas-db
Laminas\Db is a database abstraction layer for PHP, offering adapters, SQL construction, result sets, metadata, and TableGateway/RowGateway patterns. It supports PDO and driver-based connections for portable database code in Laminas apps.
laminas/laminas-db is not a Laravel package — there is no service provider, no facade, no auto-discovery. It is a standalone DB abstraction (Adapter → Sql → Statement → ResultSet → TableGateway/RowGateway) that you wire up manually, usually inside a repository or a dedicated reporting service.
Minimal setup
composer require laminas/laminas-db
Read in this order: docs.laminas.dev/laminas-db/adapters → /sql → /result-set → /table-gateway → /row-gateway. Skip the MVC integration chapter; it targets Laminas MVC, not Laravel.
Cheapest first integration — reuse Laravel's PDO so you don't open a second connection pool:
use Laminas\Db\Adapter\Adapter;
use Laminas\Db\Adapter\Driver\Pdo\Pdo;
$adapter = new Adapter(['driver' => new Pdo(DB::connection()->getPdo())]);
Standalone config (when you genuinely want an independent connection):
$adapter = new Adapter([
'driver' => 'Pdo_Mysql', // or Pdo_Pgsql, Pdo_Sqlsrv, Mysqli, Pgsql, Oci8, IbmDb2
'hostname' => config('database.connections.mysql.host'),
'database' => config('database.connections.mysql.database'),
'username' => config('database.connections.mysql.username'),
'password' => config('database.connections.mysql.password'),
'port' => config('database.connections.mysql.port', 3306),
]);
First use case: a parameterized read, then a Sql\Select. Both are one-liners once the adapter exists:
$result = $adapter->query('SELECT id, email FROM users WHERE status = ?', [1]);
foreach ($result as $row) { /* $row is an ArrayObject */ }
$sql = new Laminas\Db\Sql\Sql($adapter);
$select = $sql->select('users')->where(['status' => 1])->limit(10)->offset(0);
$rows = $sql->prepareStatementForSqlObject($select)->execute();
Bind the adapter as a singleton in a service provider and inject it into repositories — that's the whole "getting started" story. Everything after this is a decision about whether you actually want two database layers in one app.
Repository wrapping the adapter. The dominant day-to-day pattern: one class per aggregate, adapter injected via constructor, domain objects returned (not arrays). It keeps Laminas imports out of controllers/models and makes it trivially swappable back to DB:: later.
final class UserReportRepository
{
public function __construct(private Adapter $db) {}
public function activeSince(DateTimeImmutable $since): array
{
$sql = new Sql($this->db);
$select = $sql->select('users')
->columns(['id', 'email', 'created_at'])
->where(['status' => 1])
->where->greaterThanOrEqualTo('created_at', $since->format('Y-m-d H:i:s'));
return iterator_to_array(
$sql->prepareStatementForSqlObject($select)->execute()
);
}
}
Prepared statements with ParameterContainer when you need types or named placeholders (the array form gets stringified, which matters for LIMIT/OFFSET on some drivers):
use Laminas\Db\Sql\Predicate\Expression;
use Laminas\Db\Adapter\ParameterContainer;
$select->where(new Expression('email = :email'));
$stmt = $sql->prepareStatementForSqlObject($select);
$stmt->getParameterContainer()->offsetSet('email', $email, ParameterType::STRING);
$stmt->execute();
TableGateway + HydratingResultSet when you want the package's RowDataGateway/TableDataGateway behaviour. This is the only part of the package that gives you something Laravel's query builder doesn't express as directly — a reusable table-scoped object with a hydration prototype:
$gateway = new TableGateway('users', $adapter, null, new HydratingResultSet($hydrator, new UserDto()));
$result = $gateway->select(['status' => 1]);
$user = $result->current(); // UserDto or null
HydratingResultSet is the practical bridge to Laravel-land: point a Laminas\Hydrator\ReflectionHydrator or ObjectProperty at a DTO and you get typed objects without touching Eloquent.
RowGateway for single-row write cycles:
$result = $gateway->select(['id' => $id])->current(); // RowGateway via RowGatewayFeature
$result->email = '[email protected]';
$result->save(); // issues UPDATE ... WHERE id = ?
Transactions live on the driver connection, not the adapter facade:
$conn = $adapter->getDriver()->getConnection();
$conn->beginTransaction();
try { /* ... */ $conn->commit(); }
catch (Throwable $e) { $conn->rollBack(); throw $e; }
Never mix this with DB::transaction() unless you are certain the adapter wraps the same PDO handle — Laravel's transaction counter and Laminas' will diverge and you get silent partial commits. Pick one layer per unit of work.
Cross-vendor reporting. The legitimate reason to reach for this in a Laravel app: a reporting/export module that must emit identical SQL against MySQL and SQL Server, using Platform for identifier quoting and limit emulation. Sql\Select handles the LIMIT/OFFSET vs TOP/OFFSET FETCH divergence for you.
DDL (rarely). Laminas\Db\Sql\Ddl\* can build CREATE TABLE/ALTER programmatically. It is not a migration system — no versioning, no rollback, no batch tracking. Don't use it as one; keep Laravel migrations as the schema source of truth.
Laminas interop. If you're embedding a package that expects AdapterAwareInterface, register Laminas\Db\Adapter\AdapterServiceDelegator with AdapterAwareTrait-composing classes; this injects via setDbAdapter().
It is security-only maintenance. The README explicitly marks the package feature-complete, and the TSC has declined new features. No new adapters, no PHP-version-driven improvements beyond keeping CI green. Treat every dependency on it as a long-term liability with an exit plan.
Do not use this to replace Eloquent or the query builder in a Laravel app. You'll reimplement soft deletes, timestamps, casts, model events, eager loading, and DB::listen() visibility. Scope it to legacy Zend/Laminas migrations, vendor-agnostic reporting, or a codebase already deeply invested in TableGateway. 145 stars and zero dependents is a signal about ecosystem momentum, not quality — the code is fine, the trajectory isn't.
The adapter is not a Laravel Connection. DB::transaction(), DB::listen(), query counting in Telescope/Debugbar, and assertQueryCount in tests all see nothing. If you wrap Laravel's PDO, statements execute on the same handle but Laravel's transaction bookkeeping is bypassed. If you use standalone credentials, you have two pools and two transaction scopes.
Identifier quoting is inconsistent. Sql\Select quotes via the platform; raw $adapter->query('SELECT ...') does not. Aliases with reserved words, schema-qualified names, and MySQL backtick vs SQL Server bracket quoting all bite. Pass identifiers through $adapter->getPlatform()->quoteIdentifier() when composing raw fragments.
prepareStatementForSqlObject() + execute($params) mutates and reuses the ParameterContainer. Reusing the same Sql/Select object across iterations accumulates stale values. Build a fresh Sql per call, or call $stmt->getParameterContainer()->setFromArray([...]) deliberately.
Type binding matters for LIMIT. Binding an integer as a string works with emulated prepares and fails with native ones on some drivers. Use ParameterType::INTEGER or Expression with a cast when you can't trust the driver.
HydratingResultSet::current() returns null, not false, on an empty set since 2.11.1 — this was called out as a potential BC break. Strict === false checks from old Zend code will silently pass.
Exception unwrapping. Laminas wraps driver errors in Laminas\Db\Adapter\Exception\* (e.g. InvalidQueryException). Call getPrevious() to reach the PDOException for the SQLSTATE and driver error code — otherwise your Laravel error handler and log context are useless.
Debugging: install the profiler. $adapter->setProfiler(new Laminas\Db\Adapter\Profiler\Profiler()) then inspect $profiler->getProfiles() for the exact SQL and bindings. There is no other observability hook.
lastInsertValue/SequenceFeature has a history of regressions (fixed in 2.11.1). Prefer $adapter->getDriver()->getConnection()->getLastGeneratedValue() and verify per-driver; on PostgreSQL, sequences need explicit configuration.
Ddl\Float was removed in 2.12.0 (reserved word since PHP 7). Use Ddl\Floating. Old tutorial code will fail on autoload.
MySQLi SSL flags were corrected in 2.11.4 — pin ^2.14 minimum, don't run anything below 2.11.4.
Extension points worth knowing: getResultPrototype() on each driver (inspect/replace the ResultSet prototype), custom Platform subclasses for dialect quirks, custom Hydrator strategies for value objects, and the AdapterServiceDelegator/AdapterAwareTrait pair for DI into Laminas-style classes.
Composer weight. It pulls laminas-servicemanager, laminas-hydrator, laminas-stdlib, and laminas-eventmanager into a Laravel app that already ships a container. That's a non-trivial dependency surface for a "query helper" — one more reason to keep the integration behind a single repository class you can delete in an afternoon.
How can I help you explore Laravel packages today?