Using Zend\Db TableGateway as a Minimal Data Access Layer in PHP
An architecture note on using Zend\Db (Laminas) TableGateway as a minimal, safe data access layer: requirements, trust boundaries, operational checks, and when to move on.
13 Apr 2026, 14:58 UTC

If your PHP application needs to read and write rows without pulling in a full ORM, Zend\Db\TableGateway (now maintained as laminas-db) is often the smallest component that does the job safely. It maps one table to one object, parameterizes values automatically, and stays out of the way when you need raw SQL. This note covers when it fits, the smallest workable design, where the trust boundaries sit, and the conditions that should push you to a different tool.
Requirements
TableGateway is a good fit when all of the following hold:
- You need CRUD against individual tables, not complex multi-table joins or aggregations as the primary access pattern.
- You want values bound as parameters (SQL injection protection) without writing prepared statements by hand.
- You are on PHP 7.4+ with laminas-db 2.13 or later (the Laminas rename of Zend\Db; the API is essentially the same, namespaces differ).
- You are comfortable hydrating rows into plain arrays or simple row objects rather than a rich domain model.
If you need entity graphs, lazy loading, or change tracking, this is the wrong layer — use Doctrine or hand-written SQL instead.
The smallest suitable design
One TableGateway per table, created by a factory and injected into the service that owns the business logic. Do not let controllers build adapters or gateways inline.
use Laminas\Db\Adapter\Adapter;
use Laminas\Db\ResultSet\ResultSet;
use Laminas\Db\TableGateway\TableGateway;
$adapter = new Adapter([
'driver' => 'Pdo_Mysql',
'database' => 'app',
'username' => 'app_user',
'password' => 'secret',
]);
$userTable = new TableGateway('users', $adapter, null, new ResultSet());
// In your service:
$rowset = $userTable->select(['email' => $email]);
$userTable->insert(['email' => $email, 'created_at' => date('c')]);
$userTable->update(['status' => 'active'], ['id' => $id]);
$userTable->delete(['id' => $id]);
Run this anywhere in your application bootstrap or a DI factory; it needs no special permissions beyond the database credentials. In a Laminas MVC or Mezzio app, register the gateway as a shared service via a factory so the adapter is created once per request.
Trust and data boundaries
The key boundary: anything passed as a value in the data array or the where array is bound as a parameter. Anything passed as an identifier (table name, column name) or as an Expression is interpolated into SQL and is trusted. So:
select(['email' => $_GET['email']])is safe — the value is bound.- Building a where clause by concatenating user input into a string, or letting the user pick the column name, is not safe. Whitelist sortable/filterable column names before use.
new Expression('COUNT(*)')and similar raw fragments must never contain user input.
Validation of business rules (email format, length limits) still belongs in your input layer; TableGateway only guarantees the SQL boundary, not data correctness.
Operational checks
Three cheap checks catch most production surprises:
- Class availability and version. Confirm
composer.jsonpins laminas-db >= 2.13, then runphp -r 'require "vendor/autoload.php"; echo class_exists(Laminas\Db\TableGateway\TableGateway::class) ? "ok" : "missing";'from the project root. Expected:ok. - Smoke test with SQLite. Instantiate a gateway against an in-memory SQLite adapter, insert a row, and assert
getLastInsertValue()returns an integer. This verifies the driver, binding, and result set wiring without touching production data. - Query profiling. Attach the adapter profiler (
$adapter->getProfiler()with a profiler instance) in development to log slow or unexpectedly repeated queries. Do not leave full profiling enabled in production; it holds every statement in memory for the request.
Wrap gateway calls in try/catch for Laminas\Db\Adapter\Exception\ExceptionInterface (or PDOException) and check affected-row counts on update/delete — a zero can mean a no-op where you expected a change, which is otherwise silent.
Failure modes
| Symptom | Likely cause | Response |
|---|---|---|
| Exception on insert after deploy | Schema changed (renamed/dropped column) | Fix mapping; treat as a signal to add a migration check to CI |
| Memory spike on bulk import | Row-by-row insert via gateway | Switch to Laminas\Db\Sql\Insert with multi-row values or batched transactions |
| Zero affected rows, no error | Where clause matched nothing | Check affected count and handle explicitly |
| SQL syntax error from Expression | User input reached a raw fragment | Remove the Expression or whitelist inputs — this is a security fix, not a tuning issue |
Conditions that change the design
Revisit this layer when you hit any of these:
- Queries regularly join three or more tables or aggregate — move those reads to
Laminas\Db\Sql\Selectdirectly, keeping TableGateway for writes. - Bulk operations dominate — go to batched SQL or PDO.
- You need a real domain model with relationships — adopt an ORM rather than growing TableGateway features it doesn't have.
Until then, one gateway per table, injected and profiled, is a defensible minimum.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.