Installation
composer require cta/search-bundle
Add to config/bundles.php:
CTA\SearchBundle\CTASearchBundle::class => ['all' => true],
Annotate an Entity
Use annotations (@Searchable, @Filterable, @Sortable) on Doctrine entities:
use CTA\SearchBundle\Annotation\Searchable;
use CTA\SearchBundle\Annotation\Filterable;
use CTA\SearchBundle\Annotation\Sortable;
/**
* @Searchable({"name", "description"})
* @Filterable({"status": {"values": ["active", "inactive"]}})
* @Sortable({"name": "asc", "createdAt": "desc"})
*/
class Product {}
First Query
Inject CTASearchManager and use it in a controller/service:
use CTA\SearchBundle\Manager\CTASearchManager;
public function search(CTASearchManager $searchManager) {
$results = $searchManager->search(Product::class, [
'query' => 'laptop',
'filters' => ['status' => 'active'],
'sort' => ['name' => 'asc']
]);
return $this->render('products/index.html.twig', ['results' => $results]);
}
Twig Integration Loop through results in Twig:
{% for product in results %}
{{ product.name }} ({{ product.status }})
{% endfor %}
Basic Search
$searchManager->search(Entity::class, ['query' => 'search term']);
LIKE with %query% for each @Searchable field.Filtering
$searchManager->search(Entity::class, [
'filters' => [
'status' => 'active',
'category' => ['electronics', 'books']
]
]);
@Filterable fields (including arrays for multi-value fields).Sorting
$searchManager->search(Entity::class, [
'sort' => ['price' => 'desc', 'name' => 'asc']
]);
@Sortable annotations; defaults to asc if direction omitted.Pagination
$searchManager->search(Entity::class, [
'query' => 'term',
'page' => 2,
'limit' => 20
]);
PaginatorInterface (Symfony’s PaginatorAdapter).Joined Entities Annotate relationships in entities:
/**
* @Searchable({"author.name"})
*/
class Book {}
author.name via a join.Form Handling Bind form inputs to the search parameters:
$query = $request->query->all();
$results = $searchManager->search(Product::class, $query);
API Responses Serialize results with metadata:
return $this->json([
'data' => $results->getIterator(),
'total' => $results->count(),
'filters' => $request->query->get('filters')
]);
Dynamic Annotations Override annotations at runtime:
$searchManager->search(Product::class, [
'query' => 'term',
'dynamicAnnotations' => [
'Searchable' => ['name', 'description', 'sku']
]
]);
Caching Cache frequent queries (e.g., static filters):
$cacheKey = md5(serialize($params));
$results = $cache->get($cacheKey, function() use ($searchManager, $params) {
return $searchManager->search(Entity::class, $params);
});
Performance Caveats
LIKE queries, which are slow for large datasets (>10K records).
Workaround: Add database-level full-text indexes for critical fields.@Searchable fields may trigger lazy-loading issues.
Fix: Use fetch="EAGER" or DISTINCT in queries.Annotation Overrides
dynamicAnnotations replace static annotations, not merge.
Tip: Use sparingly; prefer static annotations for clarity.Filter Values
@Filterable with values restricts input to predefined options.
Gotcha: Invalid values throw exceptions. Validate client-side first.Sorting Ambiguity
asc, but this isn’t documented.
Tip: Explicitly define directions in annotations for consistency.Case Sensitivity
LIKE searches are case-insensitive by default (MySQL/PostgreSQL).
Note: SQLite may behave differently; test thoroughly.Query Logging Enable Doctrine logging to inspect generated SQL:
$config = Setup::createAnnotationMetadataConfiguration(
[__DIR__.'/../src'],
true,
null,
null,
false
);
$config->setSQLLogger(new \Doctrine\DBAL\Logging\EchoSQLLogger());
Parameter Validation
Validate input before passing to search():
$validFilters = ['status' => ['active', 'inactive']];
if (!in_array($request->query->get('status'), $validFilters['status'])) {
throw new \InvalidArgumentException("Invalid status filter");
}
Entity Metadata Check if annotations are loaded:
$metadata = $this->container->get('doctrine')->getManager()
->getMetadataFactory()->getMetadataFor(Entity::class);
if (!$metadata->reflectionClass->hasAnnotation(Searchable::class)) {
throw new \RuntimeException("Entity not searchable");
}
Custom Query Builders
Extend CTASearchManager to add logic (e.g., Elasticsearch fallback):
class CustomSearchManager extends CTASearchManager {
public function search($entityClass, array $params) {
if (strpos($params['query'], 'advanced:') === 0) {
return $this->elasticSearchFallback($entityClass, $params);
}
return parent::search($entityClass, $params);
}
}
Annotation Processors
Override annotation handling in CTASearchExtension:
$this->container->set('cta_search.extension', function() {
return new class extends CTASearchExtension {
public function loadAnnotations(ReflectionClass $class) {
// Custom logic here
return parent::loadAnnotations($class);
}
};
});
Result Transformers Modify results post-query:
$searchManager->addPostProcessor(function($results) {
foreach ($results as $entity) {
$entity->setSearchScore($this->calculateScore($entity));
}
return $results;
});
Event Listeners Hook into search events (e.g., log searches):
$eventDispatcher->addListener(CTASearchEvents::SEARCH, function(CTASearchEvent $event) {
$this->logger->info('Search executed', [
'entity' => $event->getEntityClass(),
'query' => $event->getQuery(),
'params' => $event->getParams()
]);
});
Default Values
The bundle has no default config/packages/cta_search.yaml; all behavior is annotation-driven.
Tip: Document your entity annotations centrally (e.g., in a SEARCH_RULES.md).
Bundle Order
Register CTASearchBundle after DoctrineBundle in bundles.php to avoid metadata loading issues.
PHP 7.4+ Deprecations
The bundle uses ReflectionClass::getAttributes() (PHP 8+), but runs on PHP 7. Use a polyfill if upgrading:
if (!method_exists(\ReflectionClass::class, 'getAttributes')) {
class_alias('Doctrine\Common\Annotations\Reflection\ReflectionClass', 'ReflectionClass');
}
How can I help you explore Laravel packages today?