Skip to content

Commit c010d49

Browse files
authored
Merge pull request #1995 from guillaume-sainthillier/claude/dazzling-mayer-2PJ8g
Add Elasticsearch 9 and Elastica 9 support
2 parents 54c0db3 + 14ae946 commit c010d49

15 files changed

Lines changed: 321 additions & 39 deletions

.github/workflows/continuous-integration.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,20 @@ jobs:
4848
- '8.0.*'
4949
elasticsearch:
5050
- '8.12.0'
51+
- '9.0.0'
5152
dependencies:
5253
- 'highest'
5354
include:
5455
- php: '8.1'
5556
dependencies: 'lowest'
5657
symfony: '6.4.*'
5758
elasticsearch: '8.12.0'
59+
elastica: '^8.0'
60+
# Pin Elastica to the major matching the Elasticsearch server version
61+
- elasticsearch: '8.12.0'
62+
elastica: '^8.0'
63+
- elasticsearch: '9.0.0'
64+
elastica: '^9.0'
5865
exclude:
5966
- php: '8.1'
6067
symfony: '7.4.*'
@@ -77,6 +84,9 @@ jobs:
7784
tools: 'pecl, composer:v2, flex'
7885
extensions: 'curl, json, mbstring, mongodb, openssl'
7986

87+
- name: 'Pin Elastica to ${{ matrix.elastica }}'
88+
run: 'composer require --no-update "ruflin/elastica:${{ matrix.elastica }}"'
89+
8090
- name: "Install Composer dependencies (${{ matrix.dependencies }})"
8191
uses: "ramsey/composer-install@v3"
8292
with:

CHANGELOG-7.0.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ This changelog references the relevant changes (bug and security fixes) done
55
in 7.x versions.
66

77
### 7.1.1 (2026-xx-xx)
8+
* Added Elasticsearch 9 and Elastica 9 support.
9+
* `headers` client config is now applied via `Transport::setHeader()` so it works uniformly across Guzzle, Symfony HTTP Client, and elastic-transport's bundled Curl client.
10+
* `timeout` client config is now translated to `CURLOPT_TIMEOUT` at runtime when the active transport client is elastic-transport's bundled Curl — Guzzle/Symfony HTTP Client keep consuming the original `'timeout'` key as before.
11+
* Default config no longer injects `'headers' => []` / `'timeout' => 30` into `http_client_options`, which would otherwise break elastic-transport's bundled Curl client (PHP 8+ `ValueError` on unknown `curl_setopt_array` keys).
12+
* **Deprecated** the top-level `headers` and `timeout` client config. Move them into `client_options` — for Guzzle/Symfony HTTP Client use `headers` / `timeout`, for the bundled Curl client use `CURLOPT_HTTPHEADER` / `CURLOPT_TIMEOUT`.
813
* Fix deprecated Symfony method call.
914
* Instantiate custom repositories using the DI service locator.
1015
* Add compatibility with `doctrine/doctrine-bundle` 3.x, `doctrine/phpcr-odm` 3.x and Symfony 8.0 in highest-deps CI.

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,15 @@ Installation instructions can be found in the [documentation](doc/setup.md)
2828
Versions & Dependencies
2929
-----------------------
3030

31-
Version 7 of the FOSElasticaBundle is compatible with Elasticsearch 8. It requires Symfony 6.4 or greater. When using
31+
Version 7 of the FOSElasticaBundle is compatible with Elasticsearch 8 and 9. It requires Symfony 6.4 or greater. When using
3232
Symfony Flex there is also a [recipe to ease the setup](https://github.com/symfony/recipes-contrib/tree/master/friendsofsymfony/elastica-bundle/5.0).
3333
Earlier versions of the FOSElasticaBundle are not maintained anymore and only work with older versions of the dependencies.
3434
The following table shows the compatibilities of different versions of the bundle.
3535

36-
| FOSElasticaBundle | Elastica | Elasticsearch | Symfony | PHP |
37-
|-------------------|----------|---------------| ---------- | ----- |
38-
| [6.6] (6.x) | ^7.1 | 7.\* | ^5.4\|^6.4\|^7.1 | ^7.4\|^8.1 |
39-
| [7.1] (master) | ^8.0 | 8.\* | ^6.4\|^7.4\|^8.0 | ^8.1 |
36+
| FOSElasticaBundle | Elastica | Elasticsearch | Symfony | PHP |
37+
|-------------------|------------------|---------------| ---------- | ----- |
38+
| [6.6] (6.x) | ^7.1 | 7.\* | ^5.4\|^6.4\|^7.1 | ^7.4\|^8.1 |
39+
| [7.1] (master) | ^8.0 \|\| ^9.0 | 8.\* \|\| 9.\* | ^6.4\|^7.4\|^8.0 | ^8.1 |
4040

4141
License
4242
-------

composer.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"php": "^8.1",
3434
"pagerfanta/pagerfanta": "^3.0 || ^4.0",
3535
"psr/log": "^3.0",
36-
"ruflin/elastica": "^8.0",
36+
"ruflin/elastica": "^8.0 || ^9.0",
3737
"symfony/console": "^6.4 || ^7.4 || ^8.0",
3838
"symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0",
3939
"symfony/event-dispatcher": "^6.4 || ^7.4 || ^8.0",
@@ -51,13 +51,15 @@
5151
"jms/serializer": "^3.32",
5252
"jms/serializer-bundle": "^5.4",
5353
"knplabs/knp-components": "^4.0 || ^5.0",
54+
"nyholm/psr7": "^1.8",
5455
"php-cs-fixer/shim": "3.60.0",
5556
"phpstan/extension-installer": "^1.1",
5657
"phpstan/phpstan": "^1.12",
5758
"phpstan/phpstan-phpunit": "^1.4",
5859
"phpstan/phpstan-symfony": "^1.4",
5960
"phpunit/phpunit": "^9.5",
6061
"symfony/expression-language": "^6.4 || ^7.4 || ^8.0",
62+
"symfony/http-client": "^6.4 || ^7.4 || ^8.0",
6163
"symfony/messenger": "^6.4 || ^7.4 || ^8.0",
6264
"symfony/serializer": "^6.4 || ^7.4 || ^8.0",
6365
"symfony/twig-bundle": "^6.4 || ^7.4 || ^8.0",
@@ -85,6 +87,7 @@
8587
"config": {
8688
"allow-plugins": {
8789
"ergebnis/composer-normalize": true,
90+
"php-http/discovery": true,
8891
"phpstan/extension-installer": true
8992
}
9093
},

doc/cookbook/elastica-http-client-configuration.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@ Configuring Elastica HTTP client
44
Setting HTTP Headers
55
--------------------
66

7+
> **Deprecated since 7.1**: the top-level `headers` client option is deprecated.
8+
> Move headers into `client_options` for your HTTP client (`headers` key for
9+
> Guzzle/Symfony HTTP Client, `CURLOPT_HTTPHEADER` for elastic-transport's
10+
> bundled Curl client).
11+
712
It may be necessary to set HTTP headers on the Elastica client, for example an
813
Authorization header.
914

10-
They can be set using the `headers` configuration key:
15+
They can be set using the deprecated `headers` configuration key (still works,
16+
applied via `Transport::setHeader` so it covers every HTTP client):
1117

1218
```yaml
1319
# app/config/config.yml
@@ -59,7 +65,7 @@ fos_elastica:
5965
Setting other client options
6066
--------------------
6167

62-
Any other client option for Elastica client can be set using the `client_options` configuration key:
68+
`client_options` is a raw pass-through to the underlying HTTP client (Guzzle, Symfony HTTP Client, or elastic-transport's bundled Curl client). Configure it with whatever option names your client understands:
6369

6470
```yaml
6571
# app/config/config.yml
@@ -68,7 +74,16 @@ fos_elastica:
6874
default:
6975
hosts: ['http://example.com:80']
7076
client_options:
71-
!php/const \CURLOPT_RANDOM_FILE: /dev/urandom
77+
# Guzzle / Symfony HTTP Client
78+
timeout: 30
79+
connect_timeout: 10
7280
proxy: 'http://localhost:8125'
73-
connect_timeout: 10 # if using Guzzle
81+
82+
# elastic-transport's bundled Curl client (used when neither Guzzle nor
83+
# Symfony HTTP Client is installed). Use CURLOPT_* integer keys:
84+
!php/const \CURLOPT_TIMEOUT: 30
85+
!php/const \CURLOPT_CONNECTTIMEOUT: 10
86+
!php/const \CURLOPT_RANDOM_FILE: /dev/urandom
7487
```
88+
89+
> **Deprecated since 7.1:** the top-level `timeout` client option is no longer applied — move it into `client_options` for your specific HTTP client (`timeout` for Guzzle/Symfony, `CURLOPT_TIMEOUT` for bundled Curl).

src/DependencyInjection/Configuration.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,12 +356,22 @@ private function addClientsSection(ArrayNodeDefinition $rootNode): void
356356
->prototype('scalar')->end()
357357
->end()
358358
->arrayNode('headers')
359+
->setDeprecated(
360+
'friendsofsymfony/elastica-bundle',
361+
'7.1',
362+
'The "%node%" option is deprecated. Configure headers via "client_options" instead — for Guzzle/Symfony HTTP Client use "headers", for elastic-transport\'s bundled Curl client use CURLOPT_HTTPHEADER.',
363+
)
359364
->normalizeKeys(false)
360365
->useAttributeAsKey('name')
361366
->prototype('scalar')->end()
362367
->end()
363368
->scalarNode('timeout')
364369
->defaultValue(30)
370+
->setDeprecated(
371+
'friendsofsymfony/elastica-bundle',
372+
'7.1',
373+
'The "%node%" option is deprecated. Configure your HTTP client directly via "client_options" instead — "timeout" for Guzzle/Symfony HTTP Client, CURLOPT_TIMEOUT for elastic-transport\'s bundled Curl client.',
374+
)
365375
->end()
366376
->scalarNode('retry_on_conflict')
367377
->defaultValue(0)

src/Elastica/Client.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Elastic\Elasticsearch\Exception\ClientResponseException;
1515
use Elastic\Elasticsearch\Exception\ElasticsearchException;
1616
use Elastic\Elasticsearch\Response\Elasticsearch;
17+
use Elastic\Transport\Client\Curl;
1718
use Elastica\Client as BaseClient;
1819
use Elastica\Exception\ClientException;
1920
use FOS\ElasticaBundle\Event\ElasticaRequestExceptionEvent;
@@ -36,7 +37,55 @@ class Client extends BaseClient implements ResetInterface
3637
{
3738
public function __construct(array|string $config = [], private readonly array $forbiddenCodes = [400, 403, 404], ?LoggerInterface $logger = null)
3839
{
40+
// Reroute deprecated http_client_options.headers via Transport::setHeader so it works for any
41+
// PSR-18 client — elastic-transport's bundled Curl client ValueErrors on string keys in curl_setopt_array.
42+
$headers = [];
43+
if (\is_array($config) && \is_array($config['transport_config']['http_client_options']['headers'] ?? null)) {
44+
$headers = $config['transport_config']['http_client_options']['headers'];
45+
unset($config['transport_config']['http_client_options']['headers']);
46+
}
47+
3948
parent::__construct($config, $logger);
49+
50+
foreach ($headers as $name => $value) {
51+
$this->getTransport()->setHeader($name, $value);
52+
}
53+
54+
$this->translateOptionsForBundledCurl();
55+
}
56+
57+
/**
58+
* When the active transport client is elastic-transport's bundled Curl, swap any 'timeout' string
59+
* key (deprecated bundle alias) to CURLOPT_TIMEOUT — otherwise curl_setopt_array ValueErrors on
60+
* the unknown option.
61+
*/
62+
private function translateOptionsForBundledCurl(): void
63+
{
64+
if (!\class_exists(Curl::class)) {
65+
return;
66+
}
67+
68+
$transportClient = $this->getTransport()->getClient();
69+
if (!$transportClient instanceof Curl) {
70+
return;
71+
}
72+
73+
$optionsProperty = new \ReflectionProperty($transportClient, 'options');
74+
$options = $optionsProperty->getValue($transportClient);
75+
76+
// `timeout` -> CURLOPT_TIMEOUT
77+
if (isset($options['timeout'])) {
78+
$options[\CURLOPT_TIMEOUT] = $options['timeout'];
79+
unset($options['timeout']);
80+
}
81+
82+
// `connect_timeout` -> CURLOPT_CONNECTTIMEOUT
83+
if (isset($options['connect_timeout'])) {
84+
$options[\CURLOPT_CONNECTTIMEOUT] = $options['connect_timeout'];
85+
unset($options['connect_timeout']);
86+
}
87+
88+
$transportClient->setOptions($options);
4089
}
4190

4291
/**
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the FOSElasticaBundle package.
7+
*
8+
* (c) FriendsOfSymfony <https://friendsofsymfony.github.com/>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace FOS\ElasticaBundle\Elastica;
15+
16+
/**
17+
* Static probes for the installed Elastica library's API generation.
18+
*
19+
* Despite the name, the detection here is about the ruflin/elastica PHP library
20+
* (Elastica 8 vs Elastica 9), not the Elasticsearch server. The two move
21+
* roughly in lockstep but the runtime decisions the bundle has to make
22+
* (which template-API shape to produce, which endpoint to hit) are driven by
23+
* the client library, not the server.
24+
*/
25+
final class ElasticsearchVersionDetector
26+
{
27+
private function __construct()
28+
{
29+
}
30+
31+
/**
32+
* Returns true when the active Elastica's IndexTemplate exclusively hits the
33+
* new `_index_template` API (body must wrap `mappings`/`settings` under a
34+
* `template` key). False when it defaults to (or only supports) the legacy
35+
* `_template` API.
36+
*
37+
* Detection uses \Elastica\Request as the marker: it ships in every 8.x
38+
* release and was removed in 9.0 (documented in Elastica's UPGRADE-9.0.md
39+
* under "Removed Classes"). Cleaner than property/version probes — the
40+
* class boundary precisely matches the major-version boundary.
41+
*/
42+
public static function usesNewIndexTemplateApi(): bool
43+
{
44+
return !\class_exists(\Elastica\Request::class);
45+
}
46+
}

src/Event/PreElasticaRequestEvent.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313

1414
namespace FOS\ElasticaBundle\Event;
1515

16-
use Elastica\Request;
1716
use Symfony\Contracts\EventDispatcher\Event;
1817

1918
class PreElasticaRequestEvent extends Event
2019
{
20+
public const DEFAULT_CONTENT_TYPE = 'application/json';
21+
2122
/**
2223
* @param array<string, mixed>|string $data
2324
*/
@@ -32,7 +33,7 @@ public function __construct(
3233
* @var array<string, mixed>
3334
*/
3435
private readonly array $query,
35-
private readonly string $contentType = Request::DEFAULT_CONTENT_TYPE
36+
private readonly string $contentType = self::DEFAULT_CONTENT_TYPE
3637
) {
3738
}
3839

src/Index/MappingBuilder.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use FOS\ElasticaBundle\Configuration\IndexConfigInterface;
1515
use FOS\ElasticaBundle\Configuration\IndexTemplateConfig;
16+
use FOS\ElasticaBundle\Elastica\ElasticsearchVersionDetector;
1617
use FOS\ElasticaBundle\Event\PostIndexMappingBuildEvent;
1718
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
1819

@@ -54,11 +55,27 @@ public function buildIndexMapping(IndexConfigInterface $indexConfig): array
5455
/**
5556
* Builds mappings for an entire index template.
5657
*
57-
* @return array{mappings: TMapping, settings: TSettings, index_patterns: list<non-empty-string>}
58+
* @return array{index_patterns: list<non-empty-string>, mappings?: TMapping, settings?: TSettings, template?: array{mappings?: TMapping, settings?: TSettings}}
5859
*/
5960
public function buildIndexTemplateMapping(IndexTemplateConfig $indexTemplateConfig): array
6061
{
6162
$mapping = $this->buildIndexMapping($indexTemplateConfig);
63+
64+
if (ElasticsearchVersionDetector::usesNewIndexTemplateApi()) {
65+
$template = [];
66+
if (isset($mapping['mappings'])) {
67+
$template['mappings'] = $mapping['mappings'];
68+
unset($mapping['mappings']);
69+
}
70+
if (isset($mapping['settings'])) {
71+
$template['settings'] = $mapping['settings'];
72+
unset($mapping['settings']);
73+
}
74+
if ($template) {
75+
$mapping['template'] = $template;
76+
}
77+
}
78+
6279
$mapping['index_patterns'] = $indexTemplateConfig->getIndexPatterns();
6380

6481
return $mapping;

0 commit comments

Comments
 (0)