--- url: https://elasticbridge.dev/docs/v2/installation.md --- # Installation ElasticBridge works with both **Elasticsearch** and **OpenSearch**. You choose the backend with a single config value — the fluent query API is identical across both. ## Requirements * PHP 8.2 or 8.3 * Laravel 10.x, 11.x, or 12.x * Elasticsearch 8.x **or** OpenSearch 2.x ## Install Install the package via composer: ```bash composer require lacasera/elastic-bridge ``` Publish the config file with: ```bash php artisan vendor:publish --tag="elastic-bridge-config" ``` This is the published config file (`config/elasticbridge.php`): ```php env('SEARCH_DRIVER', 'elasticsearch'), // Authentication method for the search client // Supported: 'basic-auth', 'api-key', 'sigv4' (opensearch only) 'auth_method' => env('SEARCH_AUTH_METHOD', 'basic-auth'), // Search cluster host(s) — comma-separated for a cluster // e.g. SEARCH_HOST="https://a:9200,https://b:9200" 'host' => array_values(array_filter(array_map('trim', explode( ',', (string) env('SEARCH_HOST', 'https://localhost:9200'), )))), // Basic auth 'username' => env('SEARCH_USERNAME', 'elastic'), 'password' => env('SEARCH_PASSWORD', null), // API key auth (used when auth_method = 'api-key') 'api_key' => env('SEARCH_API_KEY', null), // SSL verification — if true, you must provide SEARCH_SSL_CERT 'verify_ssl' => env('SEARCH_VERIFY_SSL', false), 'certificate' => env('SEARCH_SSL_CERT', null), // AWS SigV4 signing (OpenSearch only, when auth_method = 'sigv4') // Requires the aws/aws-sdk-php package. 'sig_v4' => [ 'region' => env('SEARCH_AWS_REGION'), // 'es' (managed) | 'aoss' (serverless) 'service' => env('SEARCH_AWS_SERVICE', 'es'), ], // Where bridge files should be generated 'namespace' => 'App\\Bridges', ]; ``` ## Environment variables The connection settings are backend-agnostic and shared by both drivers: | Variable | Purpose | | --- | --- | | `SEARCH_DRIVER` | `elasticsearch` (default) or `opensearch` | | `SEARCH_HOST` | Host, or comma-separated list for a cluster | | `SEARCH_AUTH_METHOD` | `basic-auth`, `api-key`, or `sigv4` | | `SEARCH_USERNAME` / `SEARCH_PASSWORD` | Basic auth credentials | | `SEARCH_API_KEY` | API key (when `auth_method=api-key`) | | `SEARCH_VERIFY_SSL` / `SEARCH_SSL_CERT` | TLS verification and CA cert path | | `SEARCH_AWS_REGION` / `SEARCH_AWS_SERVICE` | AWS SigV4 (OpenSearch only) | ```dotenv SEARCH_DRIVER=elasticsearch SEARCH_HOST=https://localhost:9200 SEARCH_AUTH_METHOD=basic-auth SEARCH_USERNAME=elastic SEARCH_PASSWORD=secret ``` ::: tip Upgrading from v1? The environment variables were renamed from `ELASTICSEARCH_*` to `SEARCH_*` in v2. See the [Upgrade Guide](upgrade-guide.md) for the full list of changes. ::: See [Configuration](configuration.md) for choosing a driver, authentication methods, and running against a cluster. --- --- url: https://elasticbridge.dev/docs/v2/configuration.md --- # Configuration ElasticBridge speaks to a search cluster through a **driver**. The package figures out how each backend expects to be configured — you just supply the connection settings, and the same fluent API works either way. ## Choosing a driver Set the driver with `SEARCH_DRIVER` (or the `driver` key in `config/elasticbridge.php`): ```dotenv # Elasticsearch (default) SEARCH_DRIVER=elasticsearch # OpenSearch SEARCH_DRIVER=opensearch ``` * `elasticsearch` — uses the official `elasticsearch/elasticsearch` client (installed by default). * `opensearch` — uses `opensearch-project/opensearch-php` (installed by default). An unknown driver throws an `InvalidArgumentException` at resolve time. ## Authentication The `SEARCH_AUTH_METHOD` value selects how the client authenticates. All three methods reuse the same shared credential keys. ### Basic auth (default) ```dotenv SEARCH_AUTH_METHOD=basic-auth SEARCH_USERNAME=elastic SEARCH_PASSWORD=secret ``` ### API key ```dotenv SEARCH_AUTH_METHOD=api-key SEARCH_API_KEY=your-api-key ``` On Elasticsearch this uses the native API key auth. On OpenSearch it is sent as an `Authorization: ApiKey ` header. ### AWS SigV4 (OpenSearch only) For AWS-managed OpenSearch (or OpenSearch Serverless), sign requests with SigV4: ```dotenv SEARCH_DRIVER=opensearch SEARCH_AUTH_METHOD=sigv4 SEARCH_AWS_REGION=us-east-1 SEARCH_AWS_SERVICE=es # 'es' for managed, 'aoss' for serverless ``` SigV4 requires the AWS SDK, which is an **optional** dependency — install it only if you use this method: ```bash composer require aws/aws-sdk-php ``` AWS credentials are resolved from the standard [default provider chain](https://docs.aws.amazon.com/sdk-for-php/v3/developer-guide/guide_credentials.html) (environment variables, `~/.aws/credentials`, IAM role, etc.) — they are not config keys. If the SDK is missing or the region is not set, a clear exception is thrown. ## TLS / SSL ```dotenv SEARCH_VERIFY_SSL=true SEARCH_SSL_CERT=/path/to/http_ca.crt ``` When `SEARCH_VERIFY_SSL=true`, a certificate path is required or a `MissingEnvException` is thrown. ## Running against a cluster `SEARCH_HOST` accepts a comma-separated list of hosts: ```dotenv SEARCH_HOST="https://es1:9200,https://es2:9200,https://es3:9200" ``` How each driver handles multiple hosts differs by design: * **Elasticsearch** load-balances across every host with a built-in connection pool (round-robin + dead-node detection + failover). * **OpenSearch** uses the **first host only** — its modern client is single-endpoint by design. Front a multi-node OpenSearch cluster with a managed endpoint or a load balancer for high availability. ## Custom connections Both drivers implement `Lacasera\ElasticBridge\Connection\ConnectionInterface`, which exposes `search()`, `count()`, `index()`, and `update()`. You can bind your own implementation in a service provider if you need bespoke transport behavior: ```php use Lacasera\ElasticBridge\Connection\ConnectionInterface; $this->app->bind(ConnectionInterface::class, MyConnection::class); ``` --- --- url: https://elasticbridge.dev/docs/v2/generating-bridges.md --- # Generating Bridge Classes To get started, create a bridge with the `artisan make:bridge` command. Bridges usually live in the `App\Bridges` directory. however, this can be changed through `elasticbridge.namespace` config. ## ```bash php artisan make:bridge HotelRoom ``` ## Naming Conventions Bridges generated by the make:bridge command will be placed in the `app/Bridges` directory. Here's a basic bridge class and some key conventions: ```php asBoolean() ->matchAll() ->get(); Log::from(['logs-2025.02.01', 'logs-2025.02.02'])->get(); // write to a specific index for this call Log::into('logs-2025.02.02')->create(['message' => 'hello']); ``` --- --- url: https://elasticbridge.dev/docs/v2/retrieving-bridges.md --- # Retrieving Data After creating a bridge and its associated search index, you are ready to start retrieving data. You can think of each bridge as a powerful [query builder](builder.md) allowing you to fluently query the index associated with the bridge. ## The `all` Method The `all` method retrieves the first page of documents from the bridge's index using a `match_all` query and cursor pagination. ::: warning Bounded, not "everything" `all()` returns a **single bounded page** (default 15 records) — it does not load the entire index into memory. This prevents accidentally exceeding Elasticsearch's `max_result_window`. To walk the whole dataset, use the returned collection's `links()` cursors or see [Pagination](pagination.md). ::: ### Signature ```php public static function all(int $perPage = 15) ``` ### Parameters * **`$perPage`** (int, optional): The number of records to retrieve per page. Default is `15`. ### Usage Examples **Basic Usage:** ```php use App\Bridges\HotelRoom; // Retrieve the first page of hotel rooms (15 per page) foreach (HotelRoom::all() as $hotelRoom) { echo $hotelRoom->price; } ``` **Custom Page Size:** ```php // Retrieve the first page with 50 records foreach (HotelRoom::all(50) as $hotelRoom) { echo $hotelRoom->name . ': $' . $hotelRoom->price; } ``` **Working with the Collection:** ```php $rooms = HotelRoom::all(25); // Total number of matching documents echo 'Total: ' . $rooms->total(); // Cursor links for the next/previous page (see Pagination) // ['previous' => [...], 'next' => [...], 'total' => 123] $links = $rooms->links(); // Iterate through the results $rooms->each(function ($room) { echo $room->name; }); ``` ### When to Use Use the `all` method when you want a quick first page of documents without additional constraints. For filtered queries, use the [query builder](builder.md): ```php $filteredRooms = HotelRoom::asBoolean() ->mustMatch('advertiser', 'booking.com') ->filterByRange('price', 100, 'gt') ->get(); ``` ## Building Queries Each bridge serves as a [query builder](builder.md) that allows you to add additional constraints to queries and then invoke the `get` method to retrieve the results. ```php use App\Bridges\HotelRoom; $rooms = HotelRoom::asBoolean() ->mustMatch('advertiser', 'booking.com') ->orderBy('price', 'ASC') ->take(10) ->get(['price', 'advertiser']); // select fields // Inspect the generated body (array or JSON) $asArray = HotelRoom::asRaw()->matchAll()->toQuery(); $asJson = HotelRoom::asRaw()->matchAll()->toQuery(asJson: true); ``` ## Retrieving Single Bridges ### The `find` Method The `find` method allows you to retrieve one or more documents by their `_id` values. ### How It Works 1. Uses the `ids` query type 2. Returns a single bridge instance if you pass a single ID 3. Returns a collection of bridge instances if you pass an array of IDs 4. Returns `null` (or an empty collection) if no matching documents are found ### Usage Examples **Retrieving a Single Document:** ```php use App\Bridges\HotelRoom; $room = HotelRoom::find(1); if ($room) { echo $room->name; echo $room->price; } ``` **Retrieving Multiple Documents:** ```php use App\Bridges\HotelRoom; $rooms = HotelRoom::find([1, 2, 3, 42]); foreach ($rooms as $room) { echo $room->name . ': $' . $room->price . PHP_EOL; } echo 'Found ' . $rooms->count() . ' rooms'; ``` **Working with String IDs:** ```php use App\Bridges\Log; // Search engines often use UUID or hash-based IDs $log = Log::find('9cac02e7-d063-456f-90ad-0b145bb04fde'); ``` **Chaining with Collection Methods:** ```php $expensiveRooms = HotelRoom::find([1, 2, 3, 4, 5]) ->filter(fn ($room) => $room->price > 200) ->sortBy('price') ->values(); ``` ### Performance Considerations Retrieving documents by ID is one of the fastest operations available. When finding multiple IDs, all documents are retrieved in a single request. Select only the fields you need for a lighter response: ```php $rooms = HotelRoom::asIds() ->withValues([1, 2, 3]) ->get(['name', 'price']); ``` ## Retrieving Aggregates When interacting with bridges you may use `count`, `sum`, `max`, `min`, `avg` aggregate methods. These return scalar values. `stats` returns a Stats object and `histogram` returns a collection of Bucket objects. ```php use App\Bridges\HotelRoom; echo HotelRoom::count(); // total documents in the index echo HotelRoom::min('price'); echo HotelRoom::max('price'); echo HotelRoom::avg('price'); echo HotelRoom::sum('price'); // aggregate scoped to a query echo HotelRoom::asBoolean() ->mustMatch('advertiser', 'booking.com') ->sum('price'); ``` *** > \[!IMPORTANT] > > Aggregates can also be attached to a query using `withAggregate`, letting you retrieve both documents and aggregate results. Access the aggregate via a camel-cased method on the returned collection: `` e.g. `priceAvg()`. *** ::: tip Changed in v2 Aggregation results are now stored **on the returned collection instance** instead of a global `Collection` macro. The call syntax is unchanged (`$rooms->priceAvg()`), but multiple aggregations on one response now coexist, and results no longer leak across requests in long-lived workers (Octane, queues). ::: ```php use App\Bridges\HotelRoom; $rooms = HotelRoom::asBoolean() ->mustMatch('advertiser', 'booking.com') ->withAggregate('avg', 'price') ->get(); foreach ($rooms as $room) { echo $room->price; } // access the aggregate value echo $rooms->priceAvg(); ``` ## Stats Call `stats` to return the [stats aggregate](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-stats-aggregation.html) as a `Lacasera\ElasticBridge\DTO\Stats` instance: ```php $stats = HotelRoom::asBoolean() ->mustMatch('advertiser', 'booking.com') ->stats('price'); echo $stats->count(); echo $stats->avg(); echo $stats->max(); echo $stats->min(); echo $stats->sum(); $stats->toArray(); $stats->toCollection(); ``` ## Histogram and Ranges ```php // ranges aggregate query $rooms = HotelRoom::asBoolean() ->shouldMatch('advertiser', 'booking.com') ->withAggregate('range', 'price', [ 'ranges' => [ ['from' => 50, 'to' => 500], ], ]) ->get(); dump($rooms->priceRange()); // histogram aggregate query $rooms = HotelRoom::asBoolean() ->shouldMatch('advertiser', 'booking.com') ->withAggregate('histogram', 'price', [ 'interval' => 300, ]) ->get(); dump($rooms->priceHistogram()); ``` ## Ordering, Limit and Offset ### Ordering The `orderBy` method sorts the results by a given field. The second argument is the direction, either `asc` or `desc`: ```php $rooms = HotelRoom::asBoolean() ->mustMatch('advertiser', 'booking.com') ->orderBy('price', 'desc') ->get(); ``` ::: tip Changed in v2 An invalid sort direction now throws `Lacasera\ElasticBridge\Exceptions\InvalidQuery` (only `asc` / `desc` are accepted, case-insensitively). ::: Sort by multiple fields by invoking `orderBy` repeatedly: ```php $rooms = HotelRoom::asBoolean() ->mustMatch('advertiser', 'booking.com') ->orderBy('price', 'desc') ->orderBy('created', 'asc') ->get(); ``` ### The `skip` and `take` Methods Use `skip` and `take` to limit or skip results: ```php $rooms = HotelRoom::asBoolean() ->mustMatch('advertiser', 'booking.com') ->skip(10) ->take(5) ->get(); ``` `limit` and `offset` are functionally equivalent to `take` and `skip`: ```php $rooms = HotelRoom::asBoolean() ->mustMatch('advertiser', 'booking.com') ->offset(5) ->limit(10) ->get(); ``` --- --- url: https://elasticbridge.dev/docs/v2/fulltext-search.md --- # Full Text Search ElasticBridge provides fluent methods for building full-text and related queries. Notes * All methods accept `field`, `query`, and optional `options` depending on the query type. * Use `asBoolean()` when combining queries with bool context (must/should/must\_not). ## Match Returns documents that match a provided text, number, date or boolean value. See valid [options](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html#match-field-params). ```php return HotelRoom::query() ->asRaw() ->match(field: 'advertiser', query: 'hotel', options: [ 'fuzziness' => 'auto', 'operator' => 'AND', ]) ->get(); ``` ::: warning Changed in v2 `match` is a top-level query — use it with `asRaw()` or `asMatch()`. Placing it directly in a bool context (e.g. `asBoolean()->match(...)`) now throws `Lacasera\ElasticBridge\Exceptions\InvalidQuery`, because `bool` only accepts `must`/`should`/`must_not`/`filter`. To match inside a bool query, use `mustMatch()` / `shouldMatch()`. ::: ### Or Match Convenience for a `match` with `operator = or`. ```php return HotelRoom::asRaw() ->orMatch('advertiser', 'hotel') ->get(); ``` ## Match Phrase Searches for the exact sequence of words in a field. See valid [options](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query-phrase.html#match-phrase-field-params). ```php return HotelRoom::matchPhrase('title', 'new york hotel', [ 'slop' => 1, ])->get(); ``` ## Multi Match Builds on match to allow multi-field queries. The `field` parameter can be a string or an array of fields. ```php return HotelRoom::multiMatch( field: ['advertiser', 'service_type'], query: 'hotel' )->get(); ``` ::: tip Changed in v2 `multiMatch()` and `matchPhrase()` no longer need `asRaw()`. They have no term level of their own, so they now nest themselves as a `bool` `must` clause automatically, producing valid DSL that composes with other bool clauses: ```json { "query": { "bool": { "must": [ { "multi_match": { "query": "hotel", "fields": ["advertiser", "service_type"] } } ] } } } ``` ::: ## Bool helpers ```php // must match within bool context HotelRoom::asBoolean() ->mustMatch('advertiser', 'booking.com') ->get(); // match all within should or must contexts HotelRoom::asRaw()->shouldMatchAll(); HotelRoom::asRaw()->matchAll(); ``` Chaining multiple clauses of the same type is fully supported and produces an array of clause objects: ```php HotelRoom::asBoolean() ->mustMatch('advertiser', 'booking.com') ->mustMatch('city', 'accra') ->get(); ``` Unavailable in this version * `match_phrase_prefix` and `match_bool_prefix` are not implemented at this time. --- --- url: https://elasticbridge.dev/docs/v2/filters.md --- # Filters ElasticBridge includes helpers for common filters, including term, range, and geospatial filters. Use them within a bool context via `asBoolean()` unless you are composing a raw payload. ## Term ```php HotelRoom::asBoolean() ->filterByTerm('code', 'usd') ->get(); ``` ## Range The valid range operators are `gt`, `gte`, `lt`, and `lte`. ```php HotelRoom::asBoolean() ->filterByRange('price', 100, 'gte') ->filterByRange('price', 500, 'lte') ->get(); ``` Alternatively, chain multiple range operators using `range()`: ```php HotelRoom::asBoolean() ->range('price', 'gte', 100) ->range('price', 'lte', 500) ->get(); ``` ::: warning Changed in v2 The deprecated `from` / `to` range operators were removed — Elasticsearch dropped them from the `range` query in 7.x. Passing an invalid operator now throws `Lacasera\ElasticBridge\Exceptions\InvalidQuery` with the list of allowed values, instead of building a query the cluster would reject. ::: ## Geo Filters ### Geo Bounding Box ```php HotelRoom::asBoolean() ->filterByGeoBoundingBox( 'hotel.location', ['lat' => 10, 'lon' => 10], ['lat' => 0, 'lon' => 0] ) ->get(); ``` ### Geo Distance ```php HotelRoom::asBoolean() ->filterByGeoDistance( 'hotel.location', distance: 5, latitude: 40.71, longitude: -74.0, distanceType: 'arc' ) ->get(); ``` ### Geo Polygon ```php HotelRoom::asBoolean() ->filterByGeoPolygon('hotel.location', points: [ ['lat' => 40.73, 'lon' => -74.1], ['lat' => 40.01, 'lon' => -71.12], ['lat' => 41.12, 'lon' => -71.12], ]) ->get(); ``` ### Geo Distance Range ```php HotelRoom::asBoolean() ->filterByGeoDistanceRange( 'hotel.location', from: 1, to: 5, latitude: 40.71, longitude: -74.0, unit: 'km' ) ->get(); ``` ### Geo Shape Envelope ```php HotelRoom::asBoolean() ->filterByGeoShape('hotel.location', coordinates: [ [13.0, 53.0], // top-left [14.0, 52.0], // bottom-right ]) ->get(); ``` --- --- url: https://elasticbridge.dev/docs/v2/casting.md --- # Attribute Casting ElasticBridge supports Eloquent-style attribute casting. Declare a `$casts` array (or a `casts()` method) on a bridge, and stored document values are converted to rich PHP types on access, back to storable values on write, and normalized in `toArray()`/`toJson()`. ```php 'boolean', 'price' => 'decimal:2', 'published_at' => 'datetime', 'currency' => Currency::class, // backed enum ]; } ``` ```php $product = Product::find(1); $product->in_stock; // bool $product->price; // "19.99" (string, 2 dp) $product->published_at; // Carbon instance $product->currency; // Currency enum ``` ## Supported cast types | Cast | Result | | --- | --- | | `array`, `json` | array | | `object` | `stdClass` | | `collection` | `Illuminate\Support\Collection` | | `boolean` | bool | | `integer` | int | | `real`, `float`, `double` | float | | `decimal:` | string with `n` decimals | | `string` | string | | `date` | `Carbon` (start of day) | | `datetime`, `datetime:` | `Carbon` | | `immutable_date`, `immutable_datetime` | `CarbonImmutable` | | `timestamp` | int (unix timestamp) | | `encrypted`, `encrypted:array`, `encrypted:collection`, `encrypted:object` | decrypted value | | `hashed` | stored hash (write-only transform) | | `BackedEnum::class` | the enum instance | | `AsStringable::class` | `Illuminate\Support\Stringable` | | `AsArrayObject::class` | `ArrayObject` | | `AsCollection::class`, `AsCollection::of(...)` | `Collection` | | `AsEnumCollection::of(...)`, `AsEnumArrayObject::of(...)` | enum collection | | Custom cast (`CastsAttributes`) | whatever the cast returns | The class-based casts (`As*`) are Laravel's own — reference them directly from `Illuminate\Database\Eloquent\Casts`. ## Dates Dates cast to Carbon. The default serialized format is ISO-8601; override globally with a `$dateFormat` property, or per-attribute with `datetime:`: ```php protected $dateFormat = 'Y-m-d H:i:s'; protected $casts = [ 'released_on' => 'date:Y-m-d', 'published_at' => 'datetime', ]; ``` ## Enums Backed enums cast to and from their backing value. Store an array of enum values with `AsEnumCollection`: ```php use App\Enums\Currency; use Illuminate\Database\Eloquent\Casts\AsEnumCollection; protected $casts = [ 'currency' => Currency::class, 'currencies' => AsEnumCollection::of(Currency::class), ]; ``` ## Encrypted & hashed `encrypted*` casts use Laravel's encrypter; `hashed` uses the hasher (a one-way, write-time transform). ::: warning Search implications Encrypted values are opaque ciphertext in the index — you **cannot search or aggregate on encrypted fields**. `hashed` is one-way (useful for storing secrets you only verify, never read back). Prefer these only for fields you never query. ::: ## Custom casts Implement Laravel's `Illuminate\Contracts\Database\Eloquent\CastsAttributes` (or `CastsInboundAttributes`, `Castable`, `SerializesCastableAttributes`). ::: warning Bridges are not Eloquent models Write your cast's `$model` parameter **untyped** (or type it as your bridge). A bridge is not an `Illuminate\Database\Eloquent\Model`, so a strict `Model $model` type hint will throw. Laravel's own built-in cast classes already use an untyped `$model`, so they work as-is. ::: ```php use Illuminate\Contracts\Database\Eloquent\CastsAttributes; class AsAddress implements CastsAttributes { public function get( $model, string $key, $value, array $attributes, ): Address { return new Address( $attributes['line_one'], $attributes['line_two'], ); } public function set( $model, string $key, $value, array $attributes, ): array { return [ 'line_one' => $value->lineOne, 'line_two' => $value->lineTwo, ]; } } ``` ## Note on JSON casts and Elasticsearch The `array`/`json`/`object`/`collection` casts store **native arrays/objects** in the document (so the fields remain queryable in Elasticsearch/OpenSearch), rather than the JSON-encoded strings Eloquent writes to a relational column. ## Nested fields Casts can target nested fields using dot notation (`'hotel.location.lat' => 'float'`). See [Nested Attributes](nested-attributes.md) for the full details on casting, accessors, mutators, and serialization of nested paths. --- --- url: https://elasticbridge.dev/docs/v2/accessors-mutators.md --- # Accessors & Mutators Beyond casting, bridges support Eloquent-style accessors and mutators using `Illuminate\Database\Eloquent\Casts\Attribute`. Define a method named after the attribute (camelCase) that returns an `Attribute`. ## Accessors An accessor transforms a value when you read it: ```php use Illuminate\Database\Eloquent\Casts\Attribute; use Lacasera\ElasticBridge\ElasticBridge; class Product extends ElasticBridge { protected function name(): Attribute { return Attribute::make( get: fn ($value) => ucfirst((string) $value), ); } } ``` ```php $product->name; // "Deluxe" even if stored as "deluxe" ``` The closure also receives the raw document as its second argument: ```php protected function displayName(): Attribute { return Attribute::make( get: fn ($value, array $attributes) => $attributes['brand'].' '.$attributes['sku'], ); } ``` ## Mutators Add a `set` closure to transform a value before it is stored: ```php protected function name(): Attribute { return Attribute::make( get: fn ($value) => ucfirst((string) $value), set: fn ($value) => strtolower((string) $value), ); } ``` ```php $product->name = 'DELUXE'; // stored in _source as "deluxe" ``` A mutator may write **multiple** document fields by returning an array: ```php protected function address(): Attribute { return Attribute::make( get: fn ($value, array $attributes) => new Address( $attributes['line_one'], $attributes['line_two'], ), set: fn (Address $value) => [ 'line_one' => $value->lineOne, 'line_two' => $value->lineTwo, ], ); } ``` ## Appending accessors to output Accessors that aren't backed by a stored field can be added to `toArray()`/`toJson()` via `$appends`: ```php class Product extends ElasticBridge { protected $appends = ['display_name']; protected function displayName(): Attribute { return Attribute::make( get: fn ($value, array $attributes) => 'Product: '.($attributes['sku'] ?? ''), ); } } ``` ```php $product->toArray(); // includes "display_name" => "Product: ABC" ``` ## Nested attributes Accessors and mutators can target nested fields — name the method the camelCase of the underscored path (`hotel.location.lat` → `hotelLocationLat()`). See [Nested Attributes](nested-attributes.md). --- --- url: https://elasticbridge.dev/docs/v2/nested-attributes.md --- # Nested Attributes Elasticsearch and OpenSearch documents are nested JSON. ElasticBridge lets you work with nested fields using **dot notation** — casts, accessors, and mutators all apply, and you can assign and retrieve nested values directly. ```php use App\Bridges\Product; $product = Product::find(1); $product->getAttribute('hotel.location.lat'); // float, cast $product->getAttribute('hotel.opened_at'); // Carbon, cast ``` ## Casting nested fields Declare casts with a dotted key. Every [cast type](casting.md) is supported at any depth: ```php class Product extends ElasticBridge { protected $casts = [ 'hotel.location.lat' => 'float', 'hotel.location.lon' => 'float', 'hotel.stars' => 'integer', 'hotel.opened_at' => 'datetime', ]; } ``` The cast applies when the value is read, when it is assigned, and when the model is serialized. ## Assigning and retrieving Use `getAttribute()` / `setAttribute()` with the dotted path (or the `{$path}` curly-brace form). Values are stored in the nested structure, and casts are applied both ways: ```php $product = new Product; // stored as float 5.6037 $product->setAttribute('hotel.location.lat', '5.6037'); // stored as int 5 $product->setAttribute('hotel.stars', '5'); // equivalent curly-brace form; stored as an ISO-8601 string $product->{'hotel.opened_at'} = '2020-03-15 12:00:00'; $product->getAttribute('hotel.location.lat'); // 5.6037 (float) $product->getAttribute('hotel.opened_at'); // Carbon instance ``` Assigning a nested key creates the intermediate objects automatically, and only the addressed leaf is written — sibling fields under the same parent are preserved. ::: warning Access contract Nested casts apply to the **dotted key**. Reading a parent as an object (`$product->hotel->location->lat`) returns the **raw** stored value without casting. Use `getAttribute('hotel.location.lat')` (or `$product->{'hotel.location.lat'}`) to get the cast value. ::: ## Nested accessors & mutators Define an accessor or mutator for a nested key by naming the method the **camelCase of the underscored path**: `hotel.location.lat` → `hotelLocationLat()`. ```php use Illuminate\Database\Eloquent\Casts\Attribute; class Product extends ElasticBridge { // appended accessor for "hotel.badge" protected $appends = ['hotel.badge']; protected function hotelBadge(): Attribute { return Attribute::make( get: fn ($value, array $attributes) => strtoupper( (string) data_get($attributes, 'hotel.name', '') ), ); } // mutator for "hotel.slug" protected function hotelSlug(): Attribute { return Attribute::make( set: fn ($value) => str($value)->slug()->value(), ); } } ``` ```php // stored as "grand-palace-hotel" $product->setAttribute('hotel.slug', 'Grand Palace Hotel'); $product->getAttribute('hotel.badge'); // "GRAND PALACE" ``` A nested accessor is serialized into `toArray()`/`toJson()` when its key is listed in `$appends`. ## Serialization `toArray()` and `toJson()` return the nested casts and appended nested accessors in place, at their dotted path, leaving unrelated sibling fields untouched: ```php $product->toArray(); // [ // 'hotel' => [ // 'name' => 'Grand Palace', // untouched // 'location' => ['lat' => 5.6037, 'lon' => -0.187], // 'opened_at' => '2020-03-15T12:00:00+00:00', // 'badge' => 'GRAND PALACE', // ], // ] ``` ## Querying nested fields Filters and full-text queries accept dotted field paths directly — they are passed to Elasticsearch/OpenSearch as-is: ```php Product::asBoolean() ->filterByRange('hotel.location.lat', 5.0, 'gte') ->mustMatch('hotel.name', 'palace') ->get(); ``` ::: tip Mapping note This works with **object-type** fields (the default), where `hotel.location.lat` addresses a sub-field. If you map a field as the Elasticsearch `nested` type, querying it requires the `nested` query DSL — build that with [`asRaw()`](builder.md) / `raw()`. ::: --- --- url: https://elasticbridge.dev/docs/v2/updates-and-inserts.md --- # Updates and Inserts ## Inserts When using elastic bridge, not only can we retrieve documents from our elastic index, we can also index new documents. Elastic bridge makes this super easy. To index a new document, just create an instance of the bridge class, set your attributes and call the save method on the bridge index. ```php status = $request->status_code; $log->message = $request->message; $log->save(); return response()->json([ 'message' => "log indexed successfully" ], 201); } } ``` > \[!TIP] > Alternatively you can use the `create` method to index a new document. `save()` returns a boolean, while `create()` returns the created document `_id`. ```php 500, 'message' => 'this is a log message' ]); dump($id); // e.g. "9cac02e7-d063-456f-90ad-0b145bb04fde" ``` > \[!IMPORTANT] > Just as like elastic search, whenever you set the `id` on a `new bridge` instance, it will be set as the `_id` of the document. ## Updates The `save` method may be used to update documents that already exist in elasticsearch. To Update a document, you should first retrieve it and set the attributes you wish to update and then call the `save` method. ```php use App\Bridges\HotelRoom; $room = HotelRoom::find(1); $room->price = 50; $room->save(); ``` In addition, you can use the `increment` or `decrement` methods to increase or decrease numeric fields via an update script. Both accept an optional `counter` (default `1`). Note that these methods update Elasticsearch, not the in-memory object; refresh or re-fetch the document to see updated values. ```php use App\Bridges\HotelRoom; $room = HotelRoom::find(1); // increase price by 3 $room->increment('price', 3); // then re-fetch to reflect changes locally $room = HotelRoom::find(1); echo $room->price; ``` ## Bulk Insert & Upsert To write many documents at once, use `bulk()` (index) or `upsert()` (update-or-insert). Each row is an associative array; a row's `id` becomes the document `_id`. ```php use App\Bridges\HotelRoom; // Bulk index — creates or overwrites by _id $result = HotelRoom::bulk([ ['id' => 1, 'price' => 100, 'advertiser' => 'booking.com'], // no id → _id is auto-generated ['price' => 200, 'advertiser' => 'expedia'], ]); // Bulk upsert — updates existing documents or // inserts them. An id is required on every row. $result = HotelRoom::upsert([ ['id' => 1, 'price' => 150], ['id' => 2, 'price' => 250], ]); ``` ### Inspecting the result Both methods return a `Lacasera\ElasticBridge\DTO\BulkResult`. Search-engine bulk responses report per-item outcomes (and don't include the document body), so the result exposes what succeeded and what failed rather than hydrated models: ```php // number of documents written successfully $result->count(); // number of documents attempted $result->total(); // true if any item failed $result->hasErrors(); // Collection of successful item metadata (_id, result, status) $result->successful(); // Collection of failed items (including their error) $result->failed(); // raw merged items $result->toArray(); ``` ### Limits and chunking Large payloads are chunked into multiple bulk requests automatically, and a hard cap guards against oversized calls. Both are configurable in `config/elasticbridge.php`: ```php 'bulk' => [ // throws BulkLimitExceeded when the input exceeds this 'max' => env('SEARCH_BULK_MAX', 10000), 'chunk_size' => env('SEARCH_BULK_CHUNK_SIZE', 500), ], ``` You can override the chunk size per call: ```php HotelRoom::bulk($rows, chunkSize: 1000); ``` > \[!IMPORTANT] > `upsert()` requires an `id` on every row. Exceeding `bulk.max` throws > `Lacasera\ElasticBridge\Exceptions\BulkLimitExceeded`. --- --- url: https://elasticbridge.dev/docs/v2/pagination.md --- # Pagination ## Basic Usage There are two pagination strategies: * Offset-based pagination via `simplePaginate(size: int, from: int = 0)` * Cursor-based pagination via `cursorPaginate(size: int, sort: array = [])` (Elasticsearch search\_after) ```php asBoolean() ->matchAll() ->orderBy('price', 'ASC') ->simplePaginate(size: 20); $rooms = $query->get(); // next page (e.g. in a controller) $nextFrom = 20; $nextPage = $query->simplePaginate(size: 20, from: $nextFrom)->get(); ``` > \[!WARNING] > Avoid using the `paginate(from and size)` method to page too deeply or request too many results at once. Search requests usually span multiple shards. Each shard must load its requested hits and the hits for any previous pages into memory. For deep pages or large sets of results, these operations can significantly increase memory and CPU usage. If not properly managed, these operations can result in degraded performance or node failures. read more [here](https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html#from-and-size-pagination) ## Cursor Pagination Cursor pagination is ideal for "Load more" UIs. It requires a deterministic sort. ```php asBoolean() ->mustMatch('advertiser', 'booking.com') ->orderBy('price', 'ASC') ->cursorPaginate(15); $rooms = $query->get(); // get next/previous pages using sort values $nextPage = $query ->cursorPaginate(15, $rooms->links()['next']) ->get(); $previousPage = $query ->cursorPaginate(15, $rooms->links()['previous']) ->get(); dump($nextPage, $previousPage); ``` ## Pagination Links For cursor pagination, `links()` returns an array with the previous and next `search_after` sort values and the `total` hit count: ```php asBoolean() ->matchAll() ->orderBy('price', 'ASC') ->cursorPaginate(15); $rooms = $query->get(); return response()->json([ 'data' => $rooms, // previous/next sort cursors + total hit count 'links' => $rooms->links(), ]); } } ``` ```json { "data": [/* ... */], "links": { "previous": [594], "next": [695], "total": 86 } } ``` --- --- url: https://elasticbridge.dev/docs/v2/testing.md --- # Testing Use the provided fake connection to test without a live search cluster. ## Faking the Connection Any bridge can call `::fake($response, $status = 200)` to bind a fake connection that returns your payload. In v2 the fake is driver-agnostic — the same call works whether the configured driver is Elasticsearch or OpenSearch. ```php use App\Bridges\HotelRoom; HotelRoom::fake([ 'hits' => [ 'total' => ['value' => 0, 'relation' => 'eq'], 'hits' => [], ], ]); $query = HotelRoom::asBoolean()->matchAll()->toQuery(); ``` ## Example PHPUnit Test ```php public function test_builds_term_filter(): void { HotelRoom::fake([ 'hits' => [ 'total' => ['value' => 0, 'relation' => 'eq'], 'hits' => [], ], ]); $query = HotelRoom::asBoolean() ->filterByTerm('code', 'usd') ->toQuery(); $this->assertSame([ 'query' => [ 'bool' => [ 'filter' => [ ['term' => ['code' => 'usd']], ], ], ], ], $query); } ``` --- --- url: https://elasticbridge.dev/docs/v2/builder.md --- # Query Builder Reference Below are commonly used methods available on the bridge builder. Chain them fluently from your bridge class. Term selection * `asBoolean()` — sets a bool query context * `asRaw()` — allows passing a raw body via `raw([...])` * `asMatch()`, `asFuzzy()`, `asIds()`, `asPrefix()`, `asRange()`, `asRegex()`, `asTerm()`, `asTerms()`, `asTermSet()`, `asWildCard()` Full-text helpers * `match(field, query, options = [])` — top-level; use with `asRaw()`/`asMatch()` * `orMatch(field, query)` * `matchPhrase(field, query, options = [])` — nests itself as a `bool` `must` clause * `multiMatch(fields, query)` — nests itself as a `bool` `must` clause Bool helpers * `shouldMatchAll(boost = 1.0)` * `matchAll(boost = 1.0)` * `mustMatch(field, value)` * `must(query, field, value)` * `mustNot(query, field, payload)` * `mustExist(field)` / `shouldExist(field)` ::: tip v2 behavior Repeating any bool clause (`mustMatch`, `shouldMatch`, `mustNot`, …) appends to an array of clause objects, so chaining multiple clauses of the same type produces valid DSL. `multiMatch` and `matchPhrase` no longer require `asRaw()` — they switch to a bool context automatically. ::: Pagination and sorting * `orderBy(field, direction = 'ASC')` — throws `InvalidQuery` on a direction other than asc/desc * `take(size)` / `limit(size)` * `skip(from)` / `offset(from)` * `simplePaginate(size = 15, from = 0)` * `cursorPaginate(size = 15, sort = [])` Execution * `all(perPage = 15, columns = ['*'])` — bounded first page via `match_all` * `get(columns = ['*'])` * `count()` * `toQuery(asJson = false)` — returns the built body as array or JSON Aggregations * `avg(field)`, `min(field)`, `max(field)`, `sum(field)` * `stats(field)` returns a Stats object * `histogram(field, interval)` returns buckets * `withAggregate(type, field, options = [])` to attach to a query (results are read from the returned collection, e.g. `$rooms->priceAvg()`) Filters * `filterByTerm(field, value)` * `filterByRange(field, value, operator)` or chain `range(field, operator, value)` — operators: `gt`, `gte`, `lt`, `lte` * Geo: `filterByGeoShape`, `filterByGeoDistance`, `filterByGeoPolygon`, `filterByGeoDistanceRange`, `filterByGeoBoundingBox` ::: tip v2 behavior Filters are valid inside a `bool` query. If you add filters without an explicit `asBoolean()`, the builder now promotes the query into a `bool` and attaches the filters, instead of silently dropping them. ::: Indexing and updates * `create(attributes)` — returns created `_id` (string) * `save()` — updates existing or creates if missing (boolean) * `increment(field, counter = 1)` / `decrement(field, counter = 1)` * `bulk(rows, chunkSize = null)` — bulk index; returns a `BulkResult` * `upsert(rows, chunkSize = null)` — bulk update-or-insert (each row needs an `id`); returns a `BulkResult` Index targeting * `from(index|array)` — override the search index for this query (wildcard/comma allowed) * `into(index)` — override the concrete write index for this call Utilities * `find(id|array)` — returns a single bridge or a collection * `withValues(values, field = null, options = [])` — for queries that accept `values` --- --- url: https://elasticbridge.dev/docs/v2/syntax.md --- # Syntax Basics ElasticBridge mirrors Eloquent-like chaining while building Elasticsearch queries. Core ideas * Choose a term context first via `as...()` (e.g. `asBoolean()`, `asMatch()`, `asRaw()`). * Chain helpers to add clauses, filters, sort, and pagination. * Call `get()` to execute or `toQuery()` to inspect the request body. Examples ```php // Bool with filters and sorting HotelRoom::asBoolean() ->matchAll() ->filterByTerm('currency', 'usd') ->orderBy('price', 'ASC') ->cursorPaginate(15) ->get(['price', 'currency']); // Raw body when you need full control HotelRoom::asRaw() ->raw(['bool' => ['must' => ['match' => ['code' => 'xoxo']]]]) ->get(); ``` --- --- url: https://elasticbridge.dev/docs/v2/upgrade-guide.md --- # Upgrade Guide (v1 → v2) v2 adds **OpenSearch support** alongside Elasticsearch and hardens the query builder. It is a major release with breaking changes — most upgrades are a config/env update plus a couple of API adjustments. ## Overview * **New:** OpenSearch driver, selectable via config (basic-auth, API key, and AWS SigV4). * **New:** multi-host clusters, driver-appropriate. * **Changed:** environment variables renamed `ELASTICSEARCH_*` → `SEARCH_*`. * **Changed:** `ConnectionInterface` now exposes operations instead of the raw client. * **Changed:** `BridgeBuilder::all()` signature. * **Removed:** `from` / `to` range operators. * **Hardened:** invalid operators/clauses now throw; bool clauses always render as arrays. ## 1. Rename environment variables All connection env vars were renamed to a backend-neutral `SEARCH_*` prefix. Update your `.env` and any deployment configs: | v1 | v2 | | --- | --- | | `ELASTICSEARCH_HOST` | `SEARCH_HOST` | | `ELASTICSEARCH_AUTH_METHOD` | `SEARCH_AUTH_METHOD` | | `ELASTICSEARCH_USERNAME` | `SEARCH_USERNAME` | | `ELASTICSEARCH_PASSWORD` | `SEARCH_PASSWORD` | | `ELASTICSEARCH_API_KEY` | `SEARCH_API_KEY` | | `ELASTICSEARCH_VERIFY_SSL` | `SEARCH_VERIFY_SSL` | | `ELASTICSEARCH_SSL_CERT` | `SEARCH_SSL_CERT` | | *(new)* | `SEARCH_DRIVER` (`elasticsearch` default) | | *(new)* | `SEARCH_AWS_REGION` / `SEARCH_AWS_SERVICE` (SigV4) | Re-publish the config to pick up the new keys (back up any customizations first): ```bash php artisan vendor:publish --tag="elastic-bridge-config" --force ``` There is **no fallback** to the old names — the old variables are ignored in v2. ## 2. `ConnectionInterface` changed If you bind a **custom connection**, the contract changed. `getClient(): Client` was removed in favor of driver-agnostic operations: ```php interface ConnectionInterface { public function search(array $params): array; public function count(array $params): array; public function index(array $params): array; public function update(array $params): bool; } ``` The built-in `ElasticConnection` and `OpenSearchConnection` still expose a concrete `getClient()` for advanced use, but it is no longer part of the interface. If you didn't implement `ConnectionInterface` yourself, no action is needed. ## 3. `all()` signature The builder's `all()` gained a page-size parameter and is now a **bounded first page** (it never requested the whole index — v1's behavior was already a single page, and the unbounded variant that could exceed `max_result_window` was removed): ```php // v1 public function all(array $columns = ['*']) // v2 public function all(int $perPage = 15, array $columns = ['*']) ``` If you called `->all(['price', 'name'])` on the builder, pass the page size first: ```php ->all(15, ['price', 'name']); ``` The static `Model::all(int $perPage = 15)` is unchanged. ## 4. `from` / `to` range operators removed Elasticsearch removed `from`/`to` from the `range` query in 7.x. Use `gt`/`gte`/`lt`/`lte`: ```php // v1 (no longer valid — throws InvalidQuery) ->range('price', 'from', 50) // v2 ->range('price', 'gte', 50) ``` Invalid range/order operators now throw `Lacasera\ElasticBridge\Exceptions\InvalidQuery` instead of building a query the cluster would reject. ## 5. `multiMatch()` / `matchPhrase()` now nest under `bool` These helpers have no term level of their own, so they now attach themselves as a `bool` `must` clause automatically — drop any preceding `asRaw()`: ```php // v1 HotelRoom::asRaw()->multiMatch(['a', 'b'], 'hotel')->get(); // v2 HotelRoom::multiMatch(['a', 'b'], 'hotel')->get(); ``` Also note: placing `match()` directly under `asBoolean()` now throws `InvalidQuery` — use `mustMatch()` / `shouldMatch()` inside a bool query. ## 6. Bool clause output is always an array Chaining multiple clauses of the same type used to produce malformed DSL. In v2 every bool clause renders as an array of clause objects. If you asserted on the exact `toQuery()` shape in tests, update expectations: ```php // v1 'must' => ['match' => ['currency' => ['query' => 'usd']]] // v2 'must' => [ ['match' => ['currency' => ['query' => 'usd']]] ] ``` ## 7. Aggregation results are instance-scoped Aggregations are no longer registered as global `Collection` macros. The retrieval syntax is unchanged (`$rooms->priceAvg()`), but `Collection::hasMacro('priceAvg')` no longer reflects them. Multiple aggregations on one response now coexist, and results don't leak across requests in long-lived workers. ## 8. Try OpenSearch (optional) To switch a project to OpenSearch, set the driver and (for AWS) install the SDK: ```dotenv SEARCH_DRIVER=opensearch SEARCH_AUTH_METHOD=sigv4 SEARCH_AWS_REGION=us-east-1 ``` ```bash composer require aws/aws-sdk-php # only for SigV4 ``` See [Configuration](configuration.md) for the full driver and auth reference. --- --- url: https://elasticbridge.dev/docs/v2/changelog.md --- # Changelog ## v2.0.0 Major release adding OpenSearch support and hardening the query builder. See the [Upgrade Guide](upgrade-guide.md) for migration steps. ### Added * **Nested attributes** — casts, accessors, and mutators work on dot-notation paths (`'hotel.location.lat' => 'float'`); nested values are assigned/retrieved by their dotted key and serialized in place in `toArray()`/`toJson()`. * **Multi-index queries** — read from a wildcard/comma index pattern (`$index = 'logs-*'`) while writing to a concrete index (`$writeIndex` / `getWriteIndex()`). Documents write back to their origin `_index`, and `from()` / `into()` override the read/write index per call. * **Attribute casting** — Eloquent-style `$casts` covering the full Laravel catalog (primitives, dates, `decimal`, enums + enum collections, `encrypted*`, `hashed`, array-object/ collection casts, and custom `CastsAttributes`), plus **accessors & mutators** via `Attribute::make` and `$appends`. * **Bulk insert & upsert** — `Model::bulk([...])` and `Model::upsert([...])` with automatic chunking, a configurable record cap (`bulk.max` / `bulk.chunk_size`), and a `BulkResult` DTO reporting per-item successes and failures. * **OpenSearch driver** selectable via `SEARCH_DRIVER` — the same fluent API works across Elasticsearch and OpenSearch. * OpenSearch authentication: basic-auth, API key, and **AWS SigV4** (`aws/aws-sdk-php` is an optional dependency, installed only when SigV4 is used). * Multi-host clusters via a comma-separated `SEARCH_HOST`. Elasticsearch load-balances across all hosts; OpenSearch uses the first (single-endpoint by design — front a cluster with a load balancer or managed endpoint). * Active query validation: invalid bool clauses, `match`/`terms_set` shapes, and order/range operators now throw `InvalidQuery` when the query is built. ### Changed * Environment variables renamed `ELASTICSEARCH_*` → `SEARCH_*` (no fallback). * `ConnectionInterface` now exposes `search()`, `count()`, `index()`, `update()` instead of a driver-specific `getClient()`. * `BridgeBuilder::all()` is now `all(int $perPage = 15, array $columns = ['*'])` and returns a bounded first page. * Bool clauses (`must`, `should`, `must_not`, `filter`) always render as arrays of clause objects, so chaining multiple clauses of the same type is valid. * `multiMatch()` and `matchPhrase()` nest as `bool` `must` clauses (no `asRaw()` needed). * `count()` sends only the `query` to the count API. * Aggregation results are stored per collection instance instead of a global `Collection` macro; multiple aggregations coexist and no longer leak across requests. ### Removed * Deprecated `from` / `to` range operators — only `gt`/`gte`/`lt`/`lte` are valid. ### Fixed * `getAttribute()` no longer errors when a hit has no `_source`. * `PaginatedCollection::links()` no longer fails on empty result sets (returns `total: 0` with empty cursors). * Filters are no longer silently dropped outside a bool context — the query is promoted to a `bool` with the filters attached. * `::fake()` no longer references a test-only class; the fake connection is driver-agnostic. * `save()` no longer mutates shared query-builder state during its existence check. *** For older releases, switch to the **v1.x** docs using the version selector above.