Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Needle Bundle Laravel Package

markup/needle-bundle

Symfony bundle from markup that integrates the Needle library into your application, handling service registration and configuration so you can start using it with minimal setup. Install via Composer, enable the bundle, and adjust configuration as needed.

View on GitHub
Deep Wiki
Context7

Getting Started

Read this first: it is not a Laravel package. markup/needle-bundle is a Symfony bundle. Its value is split across two layers:

  • Markup\NeedleBundle\* — Symfony DI wiring, config, compiler passes.
  • The core query/result/facet value objects and SearchEngineInterface — plain PHP, no Symfony dependency.

For a Laravel app you have three realistic options, in order of preference:

  1. Use the core classes only, wired through a Laravel service provider. You get the backend-agnostic criteria/result abstraction without dragging in symfony/http-kernel.
  2. Install the bundle and boot a minimal Symfony kernel inside Laravel. Almost never worth it.
  3. Don't. With 4 stars, 0 dependents and a last release of 2021-07-27, treat this as a reference design to copy (criteria → query string → result set → facet counts) rather than a dependency.

If you proceed

composer require markup/needle-bundle --dry-run   # check PHP/Symfony constraints first

The 2021 release targets Symfony 4/5-era packages and an older Solarium/Elasticsearch client. On PHP 8.2+/Symfony 6+ expect conflicts — you will likely need markup/needle alone, or a replace/fork.

First place to look: DependencyInjection/Configuration.php in the repo. It is the authoritative list of config keys and the only reliable documentation of engine wiring.

# config/packages/needle.yaml (Symfony)
needle:
    search_engines:
        catalog:
            type: solarium
            client: solarium.client
        logs:
            type: elasticsearch
            client: elasticsearch.client

In Laravel, skip the YAML and bind the collection yourself:

// app/Providers/SearchServiceProvider.php
public function register(): void
{
    $this->app->singleton(SearchEngineCollectionInterface::class, function ($app) {
        return new SearchEngineCollection([
            'catalog' => new SolariumSearchEngine($app->make(SolariumClient::class)),
        ]);
    });
}

First use case to prove out: a single query with one filter, one sort, and one facet against your existing index. If that round-trips cleanly (query built → results iterated → facet counts rendered), the abstraction is earning its keep. If you find yourself reaching past it into the Solarium client on day one, stop and use the client directly.

Class names below follow the 2.x line. Before writing anything, run find vendor/markup -name '*.php' | grep -iE 'criteria|filter|facet|resultset' and confirm namespaces — they moved between versions.

Implementation Patterns

Wrap it in one application-level service

The single most important pattern: no controller, job, or Blade view should ever touch Criteria or ResultSet. Convert at the boundary.

final class ProductSearch
{
    public function __construct(private SearchEngineCollectionInterface $engines) {}

    /** @return array{items: array, total: int, facets: array} */
    public function search(SearchRequest $request): array
    {
        $criteria = new Criteria();
        $criteria->setSearchTerm($request->term);

        foreach ($request->filters as $field => $values) {
            $criteria->addFilter(
                new Filter(new Field($field), $values, Conjunction::AND)
            );
        }

        $criteria->setSort(new Sort(new Field($request->sort), $request->direction));
        $criteria->setOffset($request->page * $request->perPage);
        $criteria->setRows($request->perPage);

        $resultSet = $this->engines->getSearchEngine('catalog')->search($criteria);

        return [
            'items'  => array_map([$this, 'hydrate'], iterator_to_array($resultSet)),
            'total'  => count($resultSet),
            'facets' => $this->facets($resultSet),
        ];
    }
}

This is the seam that lets you swap Solr → Elasticsearch, and the seam that lets you delete Needle later without touching your app.

Facets as a separate pass, not a side effect

Facet values usually need to render as toggleable links, so collect them into a plain structure while you still have the result set in hand:

private function facets(ResultSetInterface $resultSet): array
{
    $out = [];
    foreach ($resultSet->getFacetSet() as $facet) {
        foreach ($facet->getValueCounts() as $valueCount) {
            $out[$facet->getName()][$valueCount->getValue()] = $valueCount->getCount();
        }
    }
    return $out;
}

Keep the facet definition (which fields are facetable, display labels, order) in config on your side — the engine only tells you names and counts, and field names are not user-facing strings.

Hydration: do it in bulk, not per row

Result documents come back as flat string-keyed arrays. Collect IDs, then whereIn your Eloquent models and reorder in PHP to preserve relevance ranking. Never issue a query per hit — the whole point of a search abstraction is that result sets can be large.

Criteria objects are mutable — clone before variants

Multi-facet UIs need "what would these counts look like with this filter applied?" That means deriving criteria. Build a withFilter()-style helper that clones the base criteria, because mutating the shared instance is how you get counts that silently drift between requests in long-running workers (Octane, queue consumers).

Test against a fake engine

Implement SearchEngineInterface in-memory: match on the query string or the criteria's filters, return a hand-built result set. This gives you fast tests of your ProductSearch service without a live Solr, and it is also the best way to discover whether the abstraction fits your access patterns before you commit.

Two engines, deliberately

If you use both Solr and Elasticsearch (common during migrations), the collection is the useful part: same criteria, two engines, diff the result sets in a test. That is the package's genuine selling point and worth exploiting for a cutover.

Gotchas and Tips

Verify before you invest. 4 stars, 0 dependents, last release 2021. Pin the exact version in composer.json, commit a lockfile, and assume you are the maintainer. If composer require fights you, that is the answer, not an obstacle to work around.

The common denominator leaks. Criteria normalizes to something both Solr and Elasticsearch can express. The moment you need ES-specific behavior (nested queries, function_score, dis_max, fuzzy matching, phrase slop) the abstraction either cannot express it or silently drops it. Budget for an escape hatch that hands you the raw Solarium\QueryType\Select\Query\Query / ES body, and expect to use it.

Pagination semantics are not uniform. Solr is start/rows; Elasticsearch is from/size with a max_result_window of 10,000 by default. Deep pagination will produce different totals or a hard error on ES. Decide the cap in your service layer, not in a controller.

Facets and filters interact differently per backend. On Solr, filters added as filter queries do not affect facet counts the way you might expect; multi-select faceting usually needs facet exclusion ({!ex=...}) which the abstraction may not surface. Test a two-facet multi-select interaction explicitly — this is the single most common "works in dev, wrong in prod" bug for search UIs.

Totals vs. returned rows. count($resultSet) should be the total matches, not the page size. Confirm against a known index; some result set implementations conflate the two and you will only notice at page 2.

Character encoding and multi-valued fields. Values come back as strings, sometimes arrays for multi-valued fields. Normalize to arrays on the way in and out of your ProductSearch service so downstream code never branches on shape.

Debugging: log $criteria->getQueryString() (or the equivalent) at debug level on every search. It is the only way to see what the abstraction actually asked the backend for. Pair it with the underlying client's own logger — Solarium's event dispatcher and the ES client logger both give you the wire request and timing. Keep both; they disagree often enough to be worth it.

Extension points worth knowing: SearchEngineInterface for a custom backend (Typesense, Meilisearch, Postgres FTS — all realistic in a Laravel context and all absent here); custom Filter types for range/date/geo; the Symfony bundle's event listeners if you need to post-process results. For a Laravel app, only the first two matter, and both are small enough to write yourself.

The honest verdict. The abstractions (criteria, filters, sort, facet counts, result set) are the right shape and worth reading before you design your own. The package is a 2021 Symfony artifact with no Laravel story and no maintenance. Steal the interface design — a SearchEngineInterface with search(Criteria): ResultSet, plus a criteria builder that knows nothing about the backend — and implement it against whichever client you actually need. You will spend a day and own the result, which is a better position than inheriting an unmaintained dependency on day two.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons