dbt/odbc-driver
dbt/odbc-driver adds ODBC database support to Laravel by registering a custom driver, letting apps connect to ODBC data sources through Laravel’s DB manager, query builder, and Eloquent. It uses familiar DSN- and credential-based connection configuration, with PDO ODBC under the hood.
1. Confirm the environment before touching Composer. ODBC is two layers: the PHP extension (pdo_odbc) and a system-level driver manager + vendor driver. Verify all three before debugging a single line of Laravel:
php -m | grep -i odbc # pdo_odbc present?
odbcinst -q -d # unixODBC: listed drivers
isql -v MYDSN user pass # connectivity + auth, outside Laravel entirely
If isql fails, Laravel never had a chance. On Windows use the ODBC Data Source Administrator instead of odbcinst/isql.
2. Install and let discovery register the provider.
composer require dbt/odbc-driver
This is a 29-star, zero-dependents package with a single maintainer — treat the version constraint as a real decision. Pin ^1.0 (or whatever the README documents) rather than *, and commit composer.lock.
3. Add a connection in config/database.php. The driver key is the contract with the package's DatabaseManager extension; everything else mirrors a normal Laravel connection. Check the README for the exact option names this version supports, but the shape is:
'odbc' => [
'driver' => 'odbc',
'dsn' => env('ODBC_DSN', 'Driver={ODBC Driver 18 for SQL Server};Server=...;Database=...;TrustServerCertificate=yes'),
'username' => env('ODBC_USERNAME'),
'password' => env('ODBC_PASSWORD'),
'options' => [
PDO::ATTR_TIMEOUT => 10,
PDO::ATTR_CASE => PDO::CASE_NATURAL,
],
],
4. Smoke-test the connection in isolation.
php artisan tinker
>>> DB::connection('odbc')->select('select 1 as ok');
First real use case: read-only reporting against a legacy system — a SQL Server ERP, a DB2 warehouse, an AS/400 — where you cannot install a native PHP extension but the box already has an ODBC DSN. Keep the write path (migrations, Eloquent saves) out of scope until the read path is proven.
Second connection, never the default. ODBC connections are typically slow, session-stateful, and point at systems you don't own. Keep DB::connection('odbc') explicit everywhere; do not make it the default connection, and never let a queued job silently inherit it.
Repository/import layer over raw Eloquent. Because the remote schema is rarely Laravel-conventions (no id, no created_at, weird casing), wrap it:
final class LegacyOrders
{
public function recent(int $days): LazyCollection
{
return DB::connection('odbc')
->table('ORD_HDR')
->select(['ORD_NO', 'CUST_NO', 'ORD_DT'])
->where('ORD_DT', '>=', now()->subDays($days))
->orderBy('ORD_NO')
->lazy(500);
}
}
Always bound your result sets. ->lazy() / ->chunk() with a stable orderBy on a unique-ish column. Never ->get() on an unbounded ODBC table — you will pull a decade of ERP rows into PHP memory and then time out mid-transfer.
Nightly sync into your own schema. The dominant pattern: a scheduled command reads from ODBC and upserts into a local MySQL/Postgres table with a real Eloquent model. Your app then gets indexes, relations, and migrations; the ODBC side stays a dumb pipe.
LegacyOrders::recent(2)->each(function ($row) {
Order::updateOrCreate(['legacy_no' => trim($row->ORD_NO)], [...]);
});
Chunk the writes, batch the reads. Wrap the target-side writes in DB::transaction() in blocks of ~500 — a single transaction across a 200k-row sync will bloat the redo log.
Grammar/dialect alignment. ODBC is a transport, not a dialect: LIMIT vs OFFSET…FETCH vs TOP depends on the backend. If the package exposes a grammar or connection-class option, point it at the closest Laravel grammar (sqlsrv, postgres, mysql). If it ships a generic grammar, expect to lose whereDate, whereBetween on dates, JSON paths, and insertGetId — verify each before relying on it. Where the grammar gets it wrong, drop to ->select(DB::raw(...)) with bindings, not string interpolation.
Debug with DB::listen and the driver's own log. Laravel's listener gives you SQL and bindings; the actual ODBC error text comes from PDOException::getMessage() and often names the driver, not your table. Enable unixODBC tracing ([ODBC]\nTrace=Yes\nTraceFile=/tmp/odbc.log in ~/.odbc.ini or via ODBCINI) only for a single debugging session — it writes megabytes per second.
CI without the legacy system. Bind a LegacyOrders-style interface, and swap in a fake in tests. Do not attempt to run ODBC in CI unless you're prepared to containerize unixODBC + the vendor driver; most teams don't.
lastInsertId is unreliable or absent. Many ODBC drivers return "0" or throw. If you must write upstream, use insertGetId only after verifying against the actual backend; otherwise insert then SELECT the identity column back explicitly.Connection is busy with results for another command). If lazy() throws, force buffered queries via PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL or MYSQL_ATTR_USE_BUFFERED_QUERY-equivalents in options, and prefer chunk() (which closes each result set) over cursor().DB::transaction() can appear to work and roll back nothing. Test rollback behavior explicitly before trusting it.Schema:: is a coin flip. Migrations, hasTable, and the schema builder depend on grammar introspection that a generic ODBC grammar usually doesn't implement. Assume no migrations; manage the remote schema out-of-band.$row->order_no may be $row->ORDER_NO. Set PDO::ATTR_CASE deliberately and normalize in your mapping layer..env. Laravel's env() parser mangles ; and # in unquoted values — wrap the whole DSN in double quotes inside the env file, and clear config cache after any change: php artisan config:clear.max_execution_time, PDO ATTR_TIMEOUT, the ODBC driver's login timeout, and the DB's query governor are four independent limits. Set the PDO one first; it produces the clearest error.DatabaseManager extension or a small service provider so a breaking change or an abandoned repo costs you an afternoon, not a rewrite. Pin the version, and read the diff on every bump.How can I help you explore Laravel packages today?