mediamonks/mssql-bundle
Symfony bundle that adds Microsoft SQL Server support to Doctrine DBAL. Provides a custom MSSQL driver built on pdo_sqlsrv or pdo_dblib, plus schema tooling, so your Symfony app can use SQL Server for entities, migrations, and queries.
First, the honest framing. This is a Symfony bundle (MssqlBundle extends Symfony\Component\HttpKernel\Bundle\Bundle), not a Laravel package. There is no service provider, no extra.laravel auto-discovery, and its DI extension will never run in Laravel. What you actually get is a small set of Doctrine DBAL driver/connection classes (under src/Driver and src/Connection, names shift between versions — read the tree before assuming) that:
pdo_dblib (FreeTDS on *nix) or pdo_sqlsrv (Windows/ODBC) behind one driver name.nvarchar representation.So "getting started" in a Laravel app is really "can I use the driver classes without the bundle?" — usually yes, because the bundle is thin orchestration around them.
Minimal steps
composer require mediamonks/mssql-bundle
Before you write a line of code, check two things:
composer show mediamonks/mssql-bundle # what dbal constraint does it pin?
composer show doctrine/dbal # what does your app already have?
If the bundle pins doctrine/dbal:^2 and your app (Laravel 11+) is on DBAL 3/4, stop — the drivers will not satisfy the interface. This is the single most common reason this package is unusable in a modern stack. Verify the constraint first; don't discover it at runtime.
Where to look first
src/Driver/* — the AbstractDriver / concrete driver you'd hand to DriverManager::getConnection(['driverClass' => ...]).src/Connection/* — the subclass carrying the UTF-8 and transaction logic; this is the real value.DependencyInjection/ and README example YAML — useful only to see which service ids and parameters the author intended, i.e. as documentation of options.composer.json — PHP + DBAL version floors.First use case
The canonical one: a Laravel app on MySQL/Postgres that needs to read from a legacy ERP/CRM on SQL Server — reporting screens, CSV exports, a nightly sync job. Read-only, one or two connections, no migrations. That is the shape this bundle was built for, and it's the shape where its quirks matter least.
A pragmatic first step that often makes the bundle unnecessary:
// config/database.php — Laravel's own connector, no bundle needed
'mssql' => [
'driver' => 'sqlsrv', // requires pdo_sqlsrv + msodbcsql18
'host' => env('MSSQL_HOST'),
'port' => env('MSSQL_PORT', 1433),
'database' => env('MSSQL_DATABASE'),
'username' => env('MSSQL_USERNAME'),
'password' => env('MSSQL_PASSWORD'),
'charset' => 'UTF-8',
'options' => [
PDO::ATTR_STRINGIFY_FETCHES => false,
PDO::SQLSRV_ATTR_ENCODING => PDO::SQLSRV_ENCODING_UTF8,
],
],
Reach for the bundle only when (a) you're stuck on FreeTDS/dblib and Microsoft's Linux ODBC driver isn't an option, or (b) you've been bitten by the transaction handling and want the known workarounds rather than writing your own.
Pattern 1 — DBAL driver, no bundle. The cleanest reuse. Grab the driver class and use DBAL directly; the Symfony wiring is irrelevant.
use Doctrine\DBAL\DriverManager;
$conn = DriverManager::getConnection([
'driverClass' => \MssqlBundle\Driver\PDOSqlsrv\Driver::class, // verify exact FQCN in src/
'host' => config('database.connections.mssql.host'),
'dbname' => config('database.connections.mssql.database'),
'user' => config('database.connections.mssql.username'),
'password' => config('database.connections.mssql.password'),
]);
Caveat: the bundle connection classes are where UTF-8 conversion lives, and DriverManager will instantiate its own Connection, not the bundle's MssqlConnection. If the conversion matters, you must register the connection class explicitly ('wrapperClass' in DBAL 3) or subclass.
Pattern 2 — bridge DBAL into the Laravel query builder. Best of both worlds: keep Eloquent and the query builder, but get the bundle's PDO underneath.
// app/Database/DbalConnector.php
class DbalConnector extends \Illuminate\Database\Connectors\Connector
{
public function connect(array $config): PDO
{
$dbal = \Doctrine\DBAL\DriverManager::getConnection([
'driverClass' => \MssqlBundle\Driver\PDOSqlsrv\Driver::class,
'host' => $config['host'], 'dbname' => $config['database'],
'user' => $config['username'], 'password' => $config['password'],
'wrapperClass' => \MssqlBundle\Connection\MssqlConnection::class,
]);
return $dbal->getNativeConnection();
}
}
// AppServiceProvider::boot()
DB::extend('mssql-legacy', function (array $config, string $name) {
$config['name'] = $name;
$pdo = (new DbalConnector())->connect($config);
return new \Illuminate\Database\Connection(
$pdo, $config['database'], $config['prefix'], $config
);
});
Now DB::connection('mssql-legacy')->table('dbo.Orders')->where(...) works, DB::listen works, model binding works. Keep this in one place — it's the seam you'll debug.
Pattern 3 — two connections, explicitly scoped. Don't try to make MSSQL the default connection. Wire a named connection, and confine MSSQL access to repository classes:
final class OrderReportRepository
{
public function openOrders(): LazyCollection
{
return DB::connection('mssql-legacy')
->table('dbo.Orders')
->where('Status', 'O')
->orderBy('OrderId')
->cursor(); // streams; avoids loading 500k rows
}
}
cursor() over get() matters here — pdo_sqlsrv and pdo_dblib both buffer aggressively, and unbounded get() on a legacy table is how you OOM a queue worker.
Pattern 4 — transactions and deadlock retries. SQL Server resolves deadlocks by killing a victim (error 1205). Laravel's DB::transaction($cb, $attempts) already recognises 1205 via DetectsConcurrencyErrors, so you get retries for free if the exception surfaces as a PDOException with the number in the message:
DB::connection('mssql-legacy')->transaction(function () {
// write path
}, attempts: 3);
Under FreeTDS/dblib the message text differs from sqlsrv, and nested transactions behave differently — this is precisely the class of bug the bundle's MssqlConnection was written to paper over. Test nested DB::transaction calls explicitly; don't assume.
Pattern 5 — ETL / sync jobs. Chunk by primary key, not offset. OFFSET…FETCH requires SQL Server 2012+; on 2008 Laravel's SqlServerGrammar falls back to ROW_NUMBER(), which is slow on large tables.
DB::connection('mssql-legacy')->table('dbo.Customers')
->orderBy('CustomerId')
->chunkById(1000, fn ($rows) => $this->upsert($rows), 'CustomerId');
Integration tips
whereHas/relations to work — they don't.datetime comes back as a string, uniqueidentifier as a string, decimal as a string (correctly, with ATTR_STRINGIFY_FETCHES => false only affecting some drivers). Normalise into DTOs before it reaches your domain.Schema::connection('mssql-legacy')->getTables() will exercise your driver choice. Test it early; it's a good canary for DBAL version mismatch.mcr.microsoft.com/mssql/server:2022-latest in a service container and assert round-trip of a nvarchar row containing äöü 日本語 🙂. That single test catches most of what this bundle exists to fix.AppKernel/bundles.php is inapplicable. Only the src/Driver and src/Connection classes transfer.Driver/Connection interfaces materially. Confirm before you build anything on top; otherwise you're vendoring a fork.tds version = 7.3 (or 7.4) in freetds.conf — earlier TDS versions cannot negotiate UTF-8 and you'll get ? for every non-ASCII character, silently. With pdo_sqlsrv you need CharacterSet=UTF-8 in the DSN or PDO::SQLSRV_ATTR_ENCODING => PDO::SQLSRV_ENCODING_UTF8. The bundle's conversion layer is a fallback, not a substitute for correct driver config.nvarchar column to a varchar parameter forces implicit conversion and drops the index. Pass N'...'-equivalent values (which PDO does when encoding is set correctly) and check execution plans on slow reports.datetime vs datetime2. datetime has 3.33ms rounding and a 1753 floor. If timestamps matter, use datetime2 on the SQL Server side; the driver won't fix precision for you.SAVE TRANSACTION, but PDO_SQLSRV's handling of savepoint names and rollbacks has historically been inconsistent. If you rely on DB::transaction nesting inside a job that also wraps a transaction, test rollback behaviour explicitly rather than trusting it.DB::transaction(..., attempts: 3) re-runs the closure. Any external side effect inside it (HTTP call, dispatch) will execute twice. Keep closures pure.SET NOCOUNT ON at the top of every proc, or rowCount() lies to you under dblib. Output parameters are significantly easier through the native sqlsrv API than through PDO; if your integration is proc-heavy, that's a reason to abandon PDO here.pdo_sqlsrv needs Encrypt=1;TrustServerCertificate=1 (or a properly installed CA). This bites on first connect and looks like a credentials error.dblib wants host:1433 in the host string; sqlsrv takes port as a separate key. A single config/database.php block often can't serve both cleanly.DB::listen() and DB::enableQueryLog() work as usual once bridged. For FreeTDS, TDSDUMP=/tmp/freetds.log tsql -S host -U user -D db isolates driver-layer problems from Laravel-layer ones. Nine times out of ten the bug is in freetds.conf, not your PHP.beginTransaction), or skip it and set the PDO attributes yourself. The bundle is small enough that a targeted 30-line subclass is often safer than adopting an unmaintained dependency.app/Database/Mssql/ with a comment pointing at the origin commit. That's cheaper than discovering a PHP 8.4 incompatibility during an unrelated upgrade.pdo_sqlsrv plus Laravel's built-in sqlsrv connector covers UTF-8, transactions, and TLS without a third-party shim. Reserve this bundle for FreeTDS-constrained environments or existing installations that already depend on its transaction workarounds.How can I help you explore Laravel packages today?