fsi/datasource
FSI DataSource is a Laravel/PHP package for building reusable data sources behind tables and APIs. It offers a structured way to define queries, apply filters and sorting, handle pagination, and transform results, keeping controllers clean and consistent.
composer require fsi/datasource installs the component only. In practice most applications pull it in transitively via fsi/datasource-bundle (Symfony DI wiring, tagged drivers, request binding). Check your composer.lock first — with 0 dependents on Packagist, the component is almost always consumed through the bundle in a legacy Symfony app.README.md → docs/ (driver configuration, field types, extensions) → src/DataSource.php → src/Driver/Collection/CollectionDriver.php. The Collection driver is the smallest complete reference implementation; read it before the Doctrine ORM driver.DataSourceFactory holds registered drivers. createDataSource($driverName, $options, $name) hands off to a driver, which builds a DataSource — a container of Field objects plus bound parameters. The DataSource itself never queries anything; getResult() delegates to the driver, which translates fields + comparisons into a query. Fields are declarative descriptors: name, type (text, number, date, boolean, entity, …), allowed comparisons (eq, like, gt, between, …), and the underlying mapping (field).DataSource API is identical.use FSi\Component\DataSource\DataSourceFactory;
use FSi\Component\DataSource\Driver\Collection\CollectionDriver;
use Symfony\Component\EventDispatcher\EventDispatcher;
$factory = new DataSourceFactory(
[new CollectionDriver()],
new EventDispatcher() // Core extension (pagination + ordering) is registered by the factory
);
$dataSource = $factory->createDataSource('collection', [
'collection' => $users, // array or Traversable
'fields' => [
'name' => ['type' => 'text', 'comparison' => 'like'],
'email' => ['type' => 'text', 'comparison' => ['eq', 'like']],
'age' => ['type' => 'number', 'comparison' => 'gte'],
],
], 'users');
$dataSource->bindParameters([
'name' => ['comparison' => 'like', 'value' => 'an'],
]);
$dataSource->getPager()->setMaxResults(20);
$dataSource->getPager()->setCurrentPage(2);
$rows = $dataSource->getResult(); // array of records
$pager = $dataSource->getPager(); // getTotalResults(), getPageCount(), getCurrentPage()…
Once that loop is green, the production version is the same shape with 'doctrine-orm' and 'entity' => User::class in the options array.
Factory as a singleton in the container. Build the factory once (or let the bundle's fsi_data_source.factory service do it) and inject it. Registering drivers per-request is wasted work; drivers are stateless and cheap to share.
Field maps are configuration, not code. Centralise field definitions in a method or config array per resource, and keep the comparison list explicit. Whitelisting comparisons (['eq', 'like', 'between']) is how you control which operators the UI is allowed to submit — the field rejects anything else.
Bind from the request, then render a view. In Symfony, bindParameters($request->query->all()) (the query bag, not the whole request) followed by createView(), which gives you a form-like structure of filter widgets plus the Core extension's ordering/pagination controls. Render and validate through the view rather than hand-building a filter form.
Pagination is two numbers, not a LIMIT. The driver sets first/max results on the underlying query and then runs a separate count to populate Pager::getTotalResults() / getPageCount(). Set maxResults once per list, and read the current page from the pager after binding so page links survive filtering.
Customise the ORM query with the qb closure. The Doctrine driver accepts a query-builder callback in its options. Use it to add joins, enforce soft-delete / tenancy / ACL conditions, and set distinct() — that's where cross-cutting list scoping belongs, not in the controller.
Extensions and event subscribers for cross-cutting behaviour. The component dispatches events around binding and result fetching (PRE_BIND_PARAMETERS, POST_BIND_PARAMETERS, PRE_GET_RESULT, POST_GET_RESULT, and field-level equivalents). A custom extension subscribing to PRE_GET_RESULT is the idiomatic hook for "always filter by the current tenant" or "default sort by created_at desc". Register it on the data source after creation.
One data source per list, named. When a page hosts several lists, give each a distinct name and namespace bound parameters by that name (the bundle does this for you) so filters don't leak between tables.
Custom drivers. Implement DriverInterface (or extend the abstract driver), return a configured DataSource from getDataSource(), and translate bound fields into your own query language. Copy CollectionDriver and replace the in-memory filtering with the real back end. Drivers for Doctrine ORM, Doctrine DBAL, Elastica, and Collection ship in-tree.
Test with the Collection driver. Unit-test field/comparison logic against an array — no database, no mocks — then add one integration test per real driver against a seeded fixture to catch mapping drift.
composer.json for the PHP and symfony/event-dispatcher constraints before adopting; on PHP 8.1+ you may need --ignore-platform-reqs, a fork, or a patch for return types and deprecations. Budget for that up front.bindParameters() expects fieldName => ['value' => …, 'comparison' => …], nested under the data source name when namespaced by the bundle. Malformed or mis-nested input is silently dropped: $dataSource->getParameters() after binding is your first debugging call.eq but not like", the field definition is the culprit, not the driver.SELECT COUNT(*) runs per fetch, and joins/distinct can make it far more expensive than the page query. For heavy lists, constrain the count via the qb callback and watch the DQL in the profiler.field option to map a UI-friendly field name onto the entity property; renaming an entity column should not break URLs or saved filters.createView() builds Symfony Form objects, so outside the bundle you must supply both. If you get "no form factory" or missing-widget errors, you skipped this.createView() to see the exact keys it registers (_sort/_order-style) rather than guessing; they're also the fastest way to confirm the extension is actually loaded.src/ for function getPager and confirm whether it's on the DataSource or reached via the pagination extension before writing controller code against it.getDQL() on the ORM driver's query builder) → result count → pager totals. Ninety percent of "the filter does nothing" bugs are resolved at step one.Paginator plus a FormType for filters is less code, maintained, and does not require an event-dispatcher-era dependency. Use this package only where it already exists.How can I help you explore Laravel packages today?