Zend\Db\TableGateway: A Single-Table Wrapper, Not an ORM
Zend\Db\TableGateway is a single-table wrapper, not an ORM. Here is a worked SQLite configuration, the point where you should switch to Zend\Db\Sql, and the mistakes that leak data.
30 Jan 2026, 14:43 UTC

The short answer
Use Zend\Db\TableGateway when one class should own exactly one database table: a fixed set of columns, a fixed row shape, and CRUD calls that read like plain method names. It is not a query builder and it is not an ORM. The moment a screen needs a three-table join, the gateway stops helping and you should drop to Zend\Db\Sql.
One naming note before the code: Zend Framework was renamed to Laminas, and the same classes now ship under Laminas\Db. The API described here is unchanged; only the namespace prefix differs. The examples use the Zend\ prefix.
What the gateway actually owns
A TableGateway instance holds four things: a table name, an adapter (the database connection), a FeatureSet for opt-in behaviours such as event hooks, and a result set prototype that decides what a fetched row looks like. The adapter is resolved lazily, so several gateways can share one connection object instead of opening several.
The result set prototype is where hydration lives. Hydration means copying column values onto object properties. Pass a HydratingResultSet and every select() returns objects instead of arrays.
A worked configuration
The example below runs against an in-memory SQLite database, which is the quickest way to prove a gateway is wired correctly before pointing it at a real server. Run it from a PHP CLI script or a PHPUnit bootstrap with the Zend\Db package installed through Composer.
use Zend\Db\Adapter\Adapter;
use Zend\Db\TableGateway\TableGateway;
use Zend\Db\ResultSet\HydratingResultSet;
use Zend\Hydrator\ObjectProperty;
$adapter = new Adapter([
'driver' => 'Pdo_Sqlite',
'database' => ':memory:',
'driverOptions' => [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION],
]);
$adapter->getDriver()->getConnection()->execute(
'CREATE TABLE users ('
. 'id INTEGER PRIMARY KEY AUTOINCREMENT, '
. 'name TEXT NOT NULL, '
. 'email TEXT NOT NULL)'
);
$prototype = new HydratingResultSet(new ObjectProperty(), new User());
$users = new TableGateway('users', $adapter, null, $prototype);
$users->insert(['name' => 'Alice', 'email' => '[contact removed]']);
$row = $users->select(['id' => $users->getLastInsertValue()])->current();
echo $row->name; // Alice
Three details carry the weight. First, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION makes a bad query throw instead of returning false — the difference between a stack trace and a silently empty page. Second, the third constructor argument is the feature set; null means no event hooks are registered. Third, select(['id' => $id]) produces a parameterised WHERE id = ?. The array form binds; a raw SQL string does not.
The User class needs public $id, $name and $email properties for ObjectProperty to fill them. If your column is created_at and your property is $createdAt, ObjectProperty will not bridge the gap; you need a hydrator with a naming strategy or a hand-written one. Also check which package you have: ZF2 shipped this hydrator as Zend\Stdlib\Hydrator\ObjectProperty, ZF3 as Zend\Hydrator\ObjectProperty.
Where the abstraction stops
select(), insert(), update() and delete() cover single-table work. For a join, a subquery or a UNION, build a Zend\Db\Sql\Select and hand it to selectWith():
use Zend\Db\Sql\Sql;
$sql = new Sql($adapter);
$select = $sql->select()
->from('users')
->join('profiles', 'users.id = profiles.user_id', ['bio'])
->where(['users.status' => 'active']);
foreach ($users->selectWith($select) as $row) {
echo $row->name, ' - ', $row->bio, PHP_EOL;
}
Notice what the gateway contributed: the connection and the result set prototype. The join itself came from Sql. If most of your queries look like this, the gateway is decoration and you should use Sql directly.
Common mistakes
- Passing user input as a raw SQL string.
select()accepts a string, and that string is not parameterised. Build the condition as an array or aSqlobject so values are bound. - Assuming events fire by default. They do not. Add
Zend\Db\TableGateway\Feature\EventFeatureto the feature set, then attach listeners for the pre/post insert, update and delete events. Confirm the exact event names against the version you have installed. - Leaving the default hydrator on a wide table.
ObjectPropertywrites every matching column onto the object. If a row carries a password hash or an internal flag, that value is now on your object and onejson_encode()away from an API response. Use a hydrator that maps only the fields you intend to expose. - Constructing an adapter per gateway. The adapter is cheap to share and expensive to duplicate. Build it once and inject it.
- Treating the gateway as a repository. Business rules such as 'an order cannot ship before payment' do not belong in a class whose job is
INSERT INTO orders. Put them in a service that calls the gateway.
Checking that it works
- Run the SQLite example and assert that
$rowis an instance ofUserwith the expectedname. That single assertion proves the adapter, table name, hydrator and prototype all line up. - Assert affected-row counts, not truthiness.
update()anddelete()return the number of rows changed, and a0usually means theWHEREclause matched nothing. - Attach a profiler to the adapter (
$adapter->setProfiler(new Zend\Db\Adapter\Profiler\Profiler())) and inspect the captured statements. You want placeholders in the SQL, not inlined values. That is the check that catches an injection-prone condition before it ships.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.