rollerworks/search
Rollerworks Search is a PHP search component that turns user-friendly query strings into structured search conditions. It integrates with Doctrine ORM/DBAL and Symfony, supports custom fields, validation, and flexible parsing for building powerful, safe search interfaces.
rollerworks/search is a search abstraction for PHP: you model a search as a typed, validated SearchCondition/SearchSpecification object, and then translate that model to a backend (usually SQL through the companion Doctrine packages). It deliberately does not query your database itself — it gives you a safe, structured representation of "what the user asked for", plus the machinery to render it into a query.
Install:
composer require rollerworks/search
# and, for the SQL side, the adapter you actually need:
composer require rollerworks/search-doctrine-dbal
# or
composer require rollerworks/search-doctrine-orm
Where to look first, in order:
README.md — the conceptual overview (SearchFactory, FieldSet, SearchSpecification).docs/ — the "usage", "field types" and "input processors" pages; these are the ones that explain the mental model.tests/ — this package documents itself through its functional tests more reliably than through prose. When the docs are ambiguous about a method signature, copy the pattern from the nearest test.Minimal first use case — build a field set, build a condition, hand it to the DBAL adapter:
use Rollerworks\Component\Search\Field\Field;
use Rollerworks\Component\Search\SearchFactoryBuilder;
use Rollerworks\Component\Search\Extension\Core\CoreExtension;
use Rollerworks\Component\Search\SearchCondition;
use Rollerworks\Component\Search\Value\SingleValue;
use Rollerworks\Component\Search\ValuesBag;
$factory = (new SearchFactoryBuilder())
->addExtension(new CoreExtension())
->getSearchFactory();
$fieldSet = $factory->createFieldSetBuilder()
->add(Field::create('title', 'string'))
->add(Field::create('created_at', 'date'))
->getFieldSet();
// What a user "typed":
$condition = new SearchCondition($fieldSet, (new ValuesBag())
->add('title', new SingleValue('invoice', ['pattern' => 'contains']))
->add('created_at', new SingleValue(new \DateTime('2026-01-01'), ['comparison' => '>=']))
);
From there the DBAL adapter turns the condition into a WHERE fragment with bound parameters. The important thing to internalise on day one: you never pass user strings into SQL — you pass them into typed field values, and the adapter emits parameter placeholders.
One factory per application, built at container boot. SearchFactory is configured with extensions (core types, your custom types) and is intended to be shared. Register it as a service (or build it in a service provider) and inject it. Do not rebuild it per request in a loop.
Field set = your public search API. Treat the FieldSet like a form type: it is the whitelist of what a client may search on. Each Field has a name (the public identifier the client sends), a type, and options. Only fields registered here can ever reach the query builder, which is the single most valuable security property of this package.
Pipeline: input → condition → specification → query.
// 1. Parse untrusted input into a condition using an input processor
$processor = new JsonInput(); // or XmlInput / your own
$condition = $processor->process($fieldSet, $request->getContent(), $factory);
// 2. Wrap it in a specification when you need ordering / pagination
$specification = $factory->createSearchSpecification($fieldSet)
->setCondition($condition)
->setMaxResults(25)
->setFirstResult(($page - 1) * 25)
->setOrder(['created_at' => 'DESC']);
// 3. Translate to SQL
$sqlConditionBuilder->apply($specification, $dbalQueryBuilder);
Keeping these three steps separate pays off: the condition is serialisable and cacheable, the specification is where you impose server-side limits (max page size, allowed orderings), and only the last step knows about your schema.
Map public field names to real columns explicitly. The adapters offer a mapping hook (a field-name/column resolver on the FieldSet, or the adapter's field config). Always set it — Field::create('title', 'string') should map to a.title, not be guessed. This also lets you expose author as a field while the SQL joins users and orders by users.name.
Custom field types for domain enumerations. A select type is fine for a handful of values, but for things like "status" or a tenant-scoped ID, implement a FieldType (or a FieldTypeExtension over string) so validation happens once, at parse time, instead of scattered through controllers:
final class StatusType extends AbstractFieldType
{
public function buildType(FieldConfigInterface $config, array $options): void
{
$config->setValueComparator(new MyComparator());
}
}
Register it on the factory builder via addTypeExtension() / the type registry, and reference it as Field::create('status', 'status').
Reuse a base specification. Ordering and pagination rules (max 100 per page, default sort by id, only created_at/title orderable) belong in one place — a SearchSpecificationFactory helper — not in each controller. Same for a default "no conditions ⇒ no WHERE" short-circuit.
Test against the generated SQL, not the object graph. The unit-testable seam is $sqlConditionBuilder->getSQL($specification) / the bound parameters. Assert on the SQL string and parameter array; that catches accidental LIKE '%…%' regressions and missing bindings.
Symfony / framework integration. If there is an official bundle for your framework version, use it for wiring only, and keep your field sets as plain services. If not, the two-service setup above (factory + field set) is ~30 lines of container config and is what you want anyway.
SingleValue, array/multi-value variants and the per-value option bag (comparison, pattern) are enforced per field type. Passing a plain string where a SingleValue is expected throws at parse time — which is good, but the error messages are terse. Catch InvalidSearchConditionException (or its subclasses) at the boundary and turn it into a 400 with the violated field name.LIKE are not abstracted away for you. contains on a string field produces a case-sensitive LIKE unless your DB collation saves you. Decide deliberately whether you need lower()-based matching or a ILIKE-style custom comparator; don't discover it in production.contains/ends_with are fine for small tables and terrible for large ones. Consider restricting those patterns to specific fields, or wiring a full-text/trigram comparator as a custom value comparator.setOrder(['password' => 'ASC']) is allowed is your policy. Validate against an allowlist before building the specification — the package will happily emit whatever column you give it.null needs its own comparison semantics. Equality against NULL is not = NULL. Use the field type's is_null/empty comparison support rather than crafting a value and hoping.SearchCondition/SearchSpecification are serialisable and cacheable, but a FieldSet change (new field, changed type) can make old serialised payloads fail to unserialise. Include a schema version in the cache key.tests/ over blog posts — third-party tutorials are almost all for older releases. Add a handful of SQL-snapshot tests of your own so a dependency bump that changes generated SQL shows up in CI rather than in a customer's search results.FieldType (validation + default comparator), FieldTypeExtension (tweak an existing type), input processors (accept your own request format), and the adapter's field/column resolvers. Between those four you can cover nearly every "but our search needs…" requirement without forking.How can I help you explore Laravel packages today?