zenstruck/dsn
A small PHP library for parsing and composing DSN (data source name) strings into structured objects, with support for custom schemes, validation, and clean access to connection credentials and options. Useful for configuring database and service connections from environment variables.
zenstruck/dsn is a small, focused library for turning DSN strings (redis://user:pass@host:6379/0?timeout=10, smtp://..., https://...) into immutable value objects, and back again. v1.0.0 is the stability milestone: the public API is now frozen, Symfony 8 is supported, and deprecations are cleared. If you pin ^1.0, you can rely on the surface described below.
composer require zenstruck/dsn
Where to look first, in order:
Zenstruck\Dsn — the value object. Read its scheme(), user(), password(), host(), port(), path(), query(), fragment(), with() and __toString() methods; that list is the package.Zenstruck\Dsn\Parser — the one-method interface (parse(string $dsn): Dsn) you bind in the container.Zenstruck\Dsn\GenericParser — the default implementation; Dsn::parse() is the static convenience wrapper around it.First use case: stop hand-rolling parse_url() in a service provider. Anywhere Laravel hands you a URL-ish env var — DATABASE_URL, REDIS_URL, MAIL_DSN, AWS_*, a third-party SDK endpoint — parse it once, then read typed pieces:
use Zenstruck\Dsn;
$dsn = Dsn::parse(config('database.connections.pgsql.url'));
$dsn->host(); // "db.internal"
$dsn->port(); // 5432 (int) or null when omitted
$dsn->path(); // "app" (leading slash stripped)
$dsn->query(); // ['sslmode' => 'require'] — parsed, not a raw string
The payoff over parse_url() is the parsed query string, consistent handling of credentials, and a round-trippable object.
Bind the parser, don't call the static. Dsn::parse() is fine in a tinker session or a test, but in application code type-hint Zenstruck\Dsn\Parser and let the container inject it. It keeps call sites swappable and lets you compose parsers later.
final class RedisConnectionFactory
{
public function __construct(private \Zenstruck\Dsn\Parser $parser) {}
public function make(string $url): array
{
$dsn = $this->parser->parse($url);
return [
'host' => $dsn->host(),
'port' => $dsn->port() ?? 6379,
'password' => $dsn->password(),
'database' => (int) ltrim((string) $dsn->path(), '/'),
];
}
}
Normalise config at the boundary. The strongest pattern is a single "configuration DSN" resolver that runs in a service provider and returns plain arrays for Laravel's config. Everything downstream then deals with scalars, and the DSN string only exists at the edge of the app. This makes config:cache safe and keeps DSN parsing out of request hot paths.
Scheme-aware dispatch. When you have several backends behind one config key (cache://, redis://, array://), switch on $dsn->scheme() to select a driver. This mirrors how Laravel's own CacheManager/MailManager behave and reads naturally:
$dsn = $this->parser->parse($url);
return match ($dsn->scheme()) {
'redis' => $this->redisStore($dsn),
'array' => $this->arrayStore($dsn),
default => throw new \InvalidArgumentException("Unsupported scheme [{$dsn->scheme()}]"),
};
Use with() for derived DSNs. Because the object is immutable, $dsn->with(host: 'replica.internal') gives you a modified copy — useful for read-replica routing, per-tenant host swapping, or building a test DSN from a production one without string surgery.
Round-trip for storage and logging. (string) $dsn returns a DSN string, so you can persist it, put it in a queued job payload, or log it. Just remember credentials may be embedded — redact before logging.
Testing. In feature tests, assert on parsed values rather than raw strings ($this->assertSame('db.internal', Dsn::parse($url)->host())), and use with() in fixtures to derive variants from one canonical URL instead of copy-pasting env strings.
^6.4|^7.0|^8.0 (or similar) and older Symfony/PHP lines fell out. Before upgrading from 0.2, run composer why-not zenstruck/dsn 1.0 and check php / symfony/* constraints in your lock file. The deprecation fixes are cosmetic; the constraint bump is the real upgrade risk.parse_url(). They disagree on edge cases: no scheme, no authority, empty path, IPv6 brackets. If you migrate existing code, diff the two outputs on your real DSNs before deleting the old parser.?timeout=10 yields '10', not 10. Cast deliberately, and remember parse_str semantics apply — dots and spaces in keys get mangled, and a[]=1 becomes an array.port() is nullable. Don't assume a default; apply your own (?? 5432) at the call site so the intent is visible.p%40ss in the DSN is p@ss out of the object. Conversely, never interpolate a raw password back into a DSN string — go through the object or rawurlencode it yourself.rediss:// or pgsql:// is meaningful to your app. Do that validation yourself in the dispatch match — it's the only place that knows your drivers.Dsn\Parser (returning a Dsn subclass) and compose it in front of the generic parser. That's the intended way to add behaviour; there's no plugin registry or config file to fight with.Parser once. Add $this->app->bind(\Zenstruck\Dsn\Parser::class, \Zenstruck\Dsn\GenericParser::class) (or your chain) in a provider; without it, autowiring into constructors is a common first-run error.dump((string) $dsn) immediately after parsing — comparing the normalised output against what you wrote in .env surfaces encoding and empty-segment surprises faster than inspecting individual getters.How can I help you explore Laravel packages today?