flow-php/symfony-postgresql-bundle
Symfony bundle integrating Flow PHP with PostgreSQL, wiring the ETL data processing framework's Postgres adapter into Symfony's service container so you can build and run dataframe pipelines against Postgres with minimal configuration.
This bundle is the Symfony integration layer for Flow PHP's PostgreSQL client. It exists to do one thing: wire a configured PostgreSQL connection (and the surrounding Flow services) into the Symfony container so you can run Flow data pipelines and DataFrames against Postgres without hand-rolling PDO setup. Treat it as glue, not a feature set.
Minimal setup:
composer require flow-php/symfony-postgresql-bundle
Register the bundle (Symfony 6+ automatically via config/bundles.php, older versions need the manual entry):
// config/bundles.php
Flow\Symfony\PostgreSQLBundle\FlowPostgreSQLBundle::class => ['all' => true],
Provide a DSN through the environment so it follows normal Symfony secrets/config practices:
# .env.local
FLOW_POSTGRESQL_DSN="pgsql://app:[email protected]:5432/app_db"
Where to look first, in order:
vendor/flow-php/symfony-postgresql-bundle/ — read the DependencyInjection/ folder. With a 0-star, pre-1.0 package the extension class is the documentation; the config tree tells you every key you can set.vendor/flow-php/postgresql/ — the actual client and DataFrame integration. The bundle only exposes what this library does.php bin/console debug:container --tag=flow.postgresql (or debug:container Flow) — confirm the service ID and tags rather than guessing.First use case — read a table into a Flow DataFrame inside a console command:
use Flow\PostgreSql\Client\Client;
use Flow\PostgreSql\DataFrame\PostgreSQLDataFrame;
final class ExportOrdersCommand extends Command
{
public function __construct(private readonly Client $client) { parent::__construct(); }
protected function execute(InputInterface $in, OutputInterface $out): int
{
PostgreSQLDataFrame::from($this->client)
->read('orders')
->filter(fn (array $row) => $row['status'] === 'paid')
->write(/* csv / parquet / another sink */);
return Command::SUCCESS;
}
}
That path — command in, DataFrame out — is the realistic day-one use. If the service isn't autowirable, pull it by ID from the container to confirm the wiring before restructuring anything.
Inject the client, not PDO. The whole point of the bundle is that Client is a managed service with a lazily-opened connection. Injecting Client keeps your code testable and lets the bundle own reconnects, DSN parsing, and driver selection. Reaching for Doctrine\DBAL\Connection alongside it is fine — just don't duplicate the connection config.
Use it as the ETL edge, not the ORM. Flow PHP is a data-processing framework; its DataFrame API is columnar-oriented and designed for batch movement. A pattern that holds up:
Named connections for multi-database work. Symfony bundles of this shape usually expose a connections: map. Define a primary and an analytics replica:
# config/packages/flow_postgresql.yaml
flow_postgresql:
connections:
default:
dsn: '%env(FLOW_POSTGRESQL_DSN)%'
analytics:
dsn: '%env(FLOW_ANALYTICS_DSN)%'
Then bind the one you want per service (default is usually the alias). Verify against the bundle's Configuration.php — key names move around pre-1.0.
Wire it into Messenger for scheduled pipeline runs. A message carrying a table name and a watermark timestamp, handled by a service that injects Client, gives you retry and failure visibility for free:
#[AsMessageHandler]
final class SyncOrdersHandler
{
public function __construct(private readonly Client $client) {}
public function __invoke(SyncOrders $msg): void
{
PostgreSQLDataFrame::from($this->client)
->read('orders', /* columns */ ['id', 'total', 'updated_at'])
->filter(fn (array $r) => $r['updated_at'] > $msg->since)
->write($this->sink);
}
}
Stream, don't fetchAll, for anything large. Flow's client exposes cursor-style iteration. Reading a million rows into an array is the single fastest way to OOM a worker; the DataFrame read() on a table usually streams, but a raw $client->fetchAll($sql) does not.
Test with the real driver, not a mock. Spin up a Postgres service container in CI and point FLOW_POSTGRESQL_DSN at it. Mocking Client gives you almost no signal because the interesting failures are SQL-level and type-level.
Maturity is the headline risk. Zero stars, zero dependents, score 0. Pin an exact version in composer.json, read the CHANGELOG before every bump, and be prepared to vendor-patch the DI extension. Assume the public API is unstable and keep the bundle's surface area small in your codebase — isolate it behind your own OrderExporter-style service so a breaking change touches one file.
Driver dependency. You need ext-pgsql or pdo_pgsql depending on which client the underlying library defaults to. If you see a "no driver" exception at container build time, check php -m before touching config — this is the most common first-hour failure.
DSN format matters. Flow's DSN parser is not always Doctrine's. pgsql://user:pass@host:port/db is the safe form; query-string options (?sslmode=require) may need to be passed as explicit config keys instead. Test the DSN against a real connection early.
Lazy connections hide misconfiguration. A bad DSN often won't fail at cache:clear — it fails on first query, in whatever command or worker happens to hit it. Add a smoke-test command (bin/console flow:postgres:ping) as your first commit against this bundle.
Don't fight the container. If the service isn't autowired, debug:container and debug:autowiring postgres will show you the real ID. Explicit services.yaml aliases beat debugging the bundle's compiler pass.
Debugging SQL. Standard Symfony tooling won't see queries issued through this client (they bypass the Doctrine data collector). For early debugging, wrap the injected Client in a decorator that logs SQL + bindings to a dedicated channel. Build that decorator on day one — you'll want it permanently.
Transactions and batch writes. Confirm how the client handles transactions before writing pipelines that assume atomicity. Many Flow sinks commit per-batch; a mid-pipeline failure can leave partial data. Design idempotent sinks (upsert on a natural key, or write to a staging table then swap) rather than trusting rollback semantics you haven't verified.
Config quirks with env vars. Symfony's %env()% resolution inside bundle config trees can behave differently for bool and int nodes. If a config key isn't taking effect, check bin/console debug:config flow_postgresql — it shows the resolved values, which is the fastest way to catch a silently-empty env var.
Extension points are thin — decorate instead. With a package this young, expect few designed hooks. The reliable extension points in a Symfony app are service decoration (wrap Client), a compiler pass to rebind aliases, and Symfony events around your commands. Prefer those over subclassing library internals that may vanish in 0.2.0.
Have an exit plan. The fallback if this bundle stalls is flow-php/postgresql wired manually with a small services.yaml block — maybe 15 lines. Keep that escape hatch in mind so adopting an unproven integration never becomes a migration project.
How can I help you explore Laravel packages today?