diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f03c592..08c180a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -83,6 +83,3 @@ jobs:
- name: PHP-CS-Fixer
run: PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run --diff
-
- - name: PHPMD
- run: vendor/bin/phpmd src text phpmd.xml
diff --git a/.gitignore b/.gitignore
index a311532..53a814a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
/vendor/
.claude/
CLAUDE.local.md
+CLAUDE.md
composer.lock
.idea
composer.phar
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 02769c0..66fb08e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
# Changelog
+## [8.0.0] - 2026-06-30
+
+First stable release. The public API is now frozen: public method parameter
+names are part of the API (named arguments are supported) and will not change
+in 8.x patch/minor releases.
+
+### Changed
+
+- `Doc::refresh()` now accepts `bool` (`true`/`false`) in addition to `string` (`'wait_for'`), matching Elasticsearch's `refresh` parameter.
+
+See the 8.0.0-beta.5 and 8.0.0-beta.4 entries for the full feature set and breaking changes introduced during the beta cycle.
+
## [8.0.0-beta.5] - 2026-06-28
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index 6049386..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,67 +0,0 @@
-# ElasticKit - Elasticsearch DSL Query Builder
-
-A PHP Elasticsearch DSL query builder library.
-
-> This file is committed to the repository. Local environment variables live in `CLAUDE.local.md` (gitignored); Claude Code loads both automatically.
-
-**Versioning:** a.b.c, where `a` tracks the ES major version. `^8` suffices.
-
-> `master` tracks v8.x (ES 8.x, PHP 8.1+); the 7.x branch is maintained separately (ES 7.x, PHP 7.2+). The two lines are never merged into each other; CLAUDE.md is maintained per branch.
-
-### Commit message conventions
-
-- **Parameter names are locked**: public method parameter names are part of the API (named arguments are supported); renaming is forbidden in minor versions
-
-[Conventional Commits](https://www.conventionalcommits.org/), with an English description: `feat(query): add knn vector search`
-
-Scope is optional: dsl / index / agg / query / docs. Append `!` for breaking changes.
-
-### Changelog conventions
-
-[Keep a Changelog](https://keepachangelog.com), with English categories:
-
-- **Added** / **Changed** / **Deprecated** / **Removed** / **Fixed** / **Security**
-- Record only user-facing changes
-- Merge related changes into a single entry
-- Mark breaking changes with the `**BC:**` prefix
-
-### Release flow
-
-1. Run the full test suite
-2. Update CHANGELOG.md
-3. Commit and push
-4. Confirm the version, then tag and push
-
-### PHPDoc conventions
-
-Follow PSR-5.
-
-## TODO
-
-- [ ] **Add integration contract tests for Span and Shape queries**: unit DSL tests exist, but there is no Elasticsearch execution coverage (other query families have `tests/Integration/Dsl/*ContractTest.php`)
-- [ ] **Cover Rebuild import-failure rollback**: the `createIndex`→`import` try/catch (deletes the new index on failure) is untested; only the alias-swap rollback path is covered
-
-## Tests
-
-Tests run inside a Docker container and require these environment variables:
-
-| Variable | Purpose |
-|---|---|
-| `PHP_CONTAINER` | Docker container name |
-| `PROJECT_PATH` | Project path inside the container |
-| `PROXY_PORT` | HTTP proxy port (for pushing) |
-| `ELASTICKIT_TEST_HOST` | ES endpoint for integration tests (e.g. `https://localhost:9200`); integration tests are skipped when unset |
-
-## Pre-push checklist
-
-```bash
-docker exec $PHP_CONTAINER sh -c "cd $PROJECT_PATH && vendor/bin/phpunit --testsuite unit"
-docker exec -e ELASTICKIT_TEST_HOST=https://elasticsearch:9200 $PHP_CONTAINER sh -c "cd $PROJECT_PATH && vendor/bin/phpunit --testsuite integration"
-docker exec $PHP_CONTAINER sh -c "cd $PROJECT_PATH && vendor/bin/phpunit"
-docker exec $PHP_CONTAINER sh -c "cd $PROJECT_PATH && vendor/bin/phpstan analyse --memory-limit=256M"
-docker exec $PHP_CONTAINER sh -c "cd $PROJECT_PATH && vendor/bin/phpmd src text phpmd.xml"
-docker exec $PHP_CONTAINER sh -c "cd $PROJECT_PATH && PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --diff"
-
-# Route through the proxy when GitHub is unreachable
-https_proxy=http://127.0.0.1:$PROXY_PORT http_proxy=http://127.0.0.1:$PROXY_PORT git push
-```
diff --git a/README.md b/README.md
index 6f442f9..ece8a86 100644
--- a/README.md
+++ b/README.md
@@ -304,24 +304,6 @@ EventDispatcher::listen('search.*', function (Event $e) {
-## Long-lived processes
-
-`Index::setClient()`, `ClientManager`, `EventDispatcher`, and `Pagination` hold **static state**. In a long-lived worker (Swoole, RoadRunner, Laravel Octane) this state persists across requests, so a worker leaks the registered client, event listeners, and pagination resolvers between requests.
-
-Reset them between requests — e.g. in a request-terminated hook:
-
-```php
-use ElasticKit\Index\Support\ClientManager;
-use ElasticKit\Index\Support\EventDispatcher;
-use ElasticKit\Index\Support\Pagination;
-
-ClientManager::reset();
-EventDispatcher::reset();
-Pagination::reset();
-```
-
-PHP-FPM forks a worker per request, so this only affects persistent workers.
-
## Documentation
- [Guide](docs/guide.md) — an e-commerce order scenario, the full flow from install to production
diff --git a/README.zh.md b/README.zh.md
index ffb7583..8150afa 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -304,24 +304,6 @@ EventDispatcher::listen('search.*', function (Event $e) {
-## 常驻进程
-
-`Index::setClient()`、`ClientManager`、`EventDispatcher`、`Pagination` 持有**静态状态**。在常驻 worker(Swoole、RoadRunner、Laravel Octane)中,这些状态跨请求保留,worker 会把已注册的客户端、事件监听器、分页解析器泄漏到下一个请求。
-
-请在请求之间重置它们——例如在请求终止钩子里:
-
-```php
-use ElasticKit\Index\Support\ClientManager;
-use ElasticKit\Index\Support\EventDispatcher;
-use ElasticKit\Index\Support\Pagination;
-
-ClientManager::reset();
-EventDispatcher::reset();
-Pagination::reset();
-```
-
-PHP-FPM 每请求 fork 一个 worker,因此本节仅影响常驻 worker。
-
## 文档
- [实践指南](docs/guide.zh.md)——电商订单场景,从安装到上线的完整流程
diff --git a/composer.json b/composer.json
index 003b25d..2fd0524 100644
--- a/composer.json
+++ b/composer.json
@@ -12,8 +12,7 @@
"require-dev": {
"phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^2.1",
- "friendsofphp/php-cs-fixer": "^3.9",
- "phpmd/phpmd": "^2.15"
+ "friendsofphp/php-cs-fixer": "^3.9"
},
"autoload": {
"psr-4": {
diff --git a/docs/index.md b/docs/index.md
index 868e4fc..c67185c 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -391,7 +391,7 @@ Always validate and filter user input before passing it to DSL methods.
| `forceMerge()` | Force-merge index segments |
| `addAlias($alias)` | Add an alias |
| `removeAlias($alias)` | Remove an alias |
-| `swapAlias($alias, $target)` | Swap where an alias points |
+| `swapAlias($alias, $fromIndex)` | Swap where an alias points |
| `getAliases()` | Get the index's aliases |
### Event list
diff --git a/docs/index.zh.md b/docs/index.zh.md
index 2a40120..6d4f64e 100644
--- a/docs/index.zh.md
+++ b/docs/index.zh.md
@@ -391,7 +391,7 @@ Index::setClient($client);
| `forceMerge()` | 强制合并索引段 |
| `addAlias($alias)` | 添加别名 |
| `removeAlias($alias)` | 移除别名 |
-| `swapAlias($alias, $target)` | 切换别名指向 |
+| `swapAlias($alias, $fromIndex)` | 切换别名指向 |
| `getAliases()` | 获取索引别名 |
### 事件列表
diff --git a/phpmd.xml b/phpmd.xml
deleted file mode 100644
index bb8e0b4..0000000
--- a/phpmd.xml
+++ /dev/null
@@ -1,56 +0,0 @@
-
-
- elastickit PHPMD ruleset
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/DSL/Node.php b/src/DSL/Node.php
index 5e663cb..5e1b059 100644
--- a/src/DSL/Node.php
+++ b/src/DSL/Node.php
@@ -15,7 +15,6 @@
* Abstract base class for DSL nodes (query types, params).
*
* @phpstan-consistent-constructor
- * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) the DSL base accumulates many thin accessors
*/
abstract class Node
{
diff --git a/src/DSL/Query.php b/src/DSL/Query.php
index 664c0c8..8062cdf 100644
--- a/src/DSL/Query.php
+++ b/src/DSL/Query.php
@@ -22,7 +22,6 @@
* Query container that combines multiple query conditions into an Elasticsearch DSL query.
*
* @phpstan-consistent-constructor
- * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*/
class Query extends Node
{
diff --git a/src/DSL/Support/RegistersAgg.php b/src/DSL/Support/RegistersAgg.php
index ecb95aa..6bba5d0 100644
--- a/src/DSL/Support/RegistersAgg.php
+++ b/src/DSL/Support/RegistersAgg.php
@@ -19,8 +19,6 @@ trait RegistersAgg
/**
* Register an aggregation under an alias into the given store.
*
- * @SuppressWarnings(PHPMD.NPathComplexity) flat type-dispatch on polymorphic input; each branch is simple
- *
* @param mixed $alias aggregation alias (string), Agg instance (when passed as the only argument), or null
* @param mixed $aggs Agg instance, array, closure, or null
* @param array $store the target aggregation store (by reference)
diff --git a/src/Index/Doc.php b/src/Index/Doc.php
index 421e5de..c98c585 100644
--- a/src/Index/Doc.php
+++ b/src/Index/Doc.php
@@ -20,9 +20,9 @@ class Doc
private int $retryOnConflict = 0;
/**
- * @var string|null
+ * @var string|bool|null
*/
- private ?string $refresh = null;
+ private string|bool|null $refresh = null;
/**
* @param string|int|null $id document id, or null/'' to let ES auto-generate
@@ -59,10 +59,10 @@ public function retryOnConflict(int $count): static
/**
* Set refresh for subsequent write operations (true/false/wait_for).
*
- * @param string $value
+ * @param string|bool $value
* @return $this
*/
- public function refresh(string $value): static
+ public function refresh(string|bool $value): static
{
$this->refresh = $value;
diff --git a/src/Index/Index.php b/src/Index/Index.php
index 20e6c39..c833e43 100644
--- a/src/Index/Index.php
+++ b/src/Index/Index.php
@@ -238,7 +238,6 @@ public function trackTotalHits(): int|bool
*
* @param array $context user-defined context passed from rebuild
* @return iterable>
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function source(array $context = []): iterable
{
diff --git a/src/Index/Rebuild.php b/src/Index/Rebuild.php
index 4256b4a..55b0dad 100644
--- a/src/Index/Rebuild.php
+++ b/src/Index/Rebuild.php
@@ -16,7 +16,6 @@
* $name is always the app-facing name. After rebuild, $name becomes an alias
* pointing to a backing index generated by rebuildName().
*
- * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*/
class Rebuild
{
diff --git a/tests/Integration/Dsl/ShapeContractTest.php b/tests/Integration/Dsl/ShapeContractTest.php
new file mode 100644
index 0000000..bd204dd
--- /dev/null
+++ b/tests/Integration/Dsl/ShapeContractTest.php
@@ -0,0 +1,75 @@
+shapeIndex !== null && static::$esClient !== null) {
+ try {
+ static::$esClient->indices()->delete(['index' => $this->shapeIndex]);
+ } catch (\Throwable $e) {
+ // best-effort cleanup
+ }
+ $this->shapeIndex = null;
+ }
+ parent::tearDown();
+ }
+
+ private function withShapeIndex(): string
+ {
+ $client = static::$esClient;
+ $index = 'ek_shape_' . bin2hex(random_bytes(4));
+ $this->shapeIndex = $index;
+
+ $client->indices()->create([
+ 'index' => $index,
+ 'body' => ['mappings' => ['properties' => ['geom' => ['type' => 'shape']]]],
+ ]);
+ $client->index(['index' => $index, 'id' => '1', 'body' => ['geom' => ['type' => 'envelope', 'coordinates' => [[0, 10], [10, 0]]]]]);
+ $client->index(['index' => $index, 'id' => '2', 'body' => ['geom' => ['type' => 'envelope', 'coordinates' => [[100, 110], [110, 100]]]]]);
+ $client->indices()->refresh(['index' => $index]);
+
+ return $index;
+ }
+
+ public function testShapeIntersects(): void
+ {
+ $index = $this->withShapeIndex();
+ // query envelope overlaps doc 1's [[0,0],[10,10]] only
+ $q = (new Query())->shape('geom', function (Shape $shape) {
+ $shape->shape(['type' => 'envelope', 'coordinates' => [[5, 15], [15, 5]]])
+ ->relation('intersects');
+ });
+ $total = static::$esClient->search(['index' => $index, 'body' => $q->toArray()])->asArray()['hits']['total']['value'] ?? 0;
+ $this->assertSame(1, $total);
+ }
+
+ public function testShapeDisjoint(): void
+ {
+ $index = $this->withShapeIndex();
+ // relation DISJOINT inverts: doc 2 (far) matches instead of doc 1
+ $q = (new Query())->shape('geom', function (Shape $shape) {
+ $shape->shape(['type' => 'envelope', 'coordinates' => [[5, 15], [15, 5]]])
+ ->relation('disjoint');
+ });
+ $total = static::$esClient->search(['index' => $index, 'body' => $q->toArray()])->asArray()['hits']['total']['value'] ?? 0;
+ $this->assertSame(1, $total);
+ }
+}
diff --git a/tests/Integration/Dsl/SpanContractTest.php b/tests/Integration/Dsl/SpanContractTest.php
new file mode 100644
index 0000000..6968f50
--- /dev/null
+++ b/tests/Integration/Dsl/SpanContractTest.php
@@ -0,0 +1,51 @@
+assertQueryEs((new Query())->spanTerm('content', 'elasticsearch'), 2);
+ }
+
+ public function testSpanNear(): void
+ {
+ // "database design" adjacent in docs 1 and 3
+ $q = (new Query())->spanNear(function (SpanNear $near) {
+ $near->clauses(function (Query $query) {
+ $query->spanTerm('content', 'database');
+ $query->spanTerm('content', 'design');
+ })->slop(1)->inOrder(true);
+ });
+ $this->assertQueryEs($q, 2);
+ }
+
+ public function testSpanOr(): void
+ {
+ // elasticsearch (docs 1,2) or php (doc 2) -> docs 1,2
+ $q = (new Query())->spanOr(function (SpanOr $or) {
+ $or->clauses(function (Query $query) {
+ $query->spanTerm('content', 'elasticsearch');
+ $query->spanTerm('content', 'php');
+ });
+ });
+ $this->assertQueryEs($q, 2);
+ }
+}
diff --git a/tests/Integration/Index/BulkContractTest.php b/tests/Integration/Index/BulkContractTest.php
index a3540d4..b913b0b 100644
--- a/tests/Integration/Index/BulkContractTest.php
+++ b/tests/Integration/Index/BulkContractTest.php
@@ -113,4 +113,19 @@ public function testOnErrorCanResendFailures(): void
$this->refreshIndex();
$this->assertSame('resend', $index->newDoc('1')->source()['title']);
}
+
+ public function testFlushThrowsWhenNoErrorHandler(): void
+ {
+ // create on the already-seeded id '1' triggers a bulk error; with no
+ // onError handler, flush() must throw a RuntimeException describing it.
+ $index = $this->makeIndex();
+ $bulk = (new Bulk($index))->create('1', ['title' => 'dup']);
+
+ try {
+ $bulk->flush();
+ $this->fail('Expected flush() to throw on bulk errors with no onError handler');
+ } catch (\RuntimeException $e) {
+ $this->assertStringContainsString('Bulk request has errors', $e->getMessage());
+ }
+ }
}
diff --git a/tests/Integration/Index/DocContractTest.php b/tests/Integration/Index/DocContractTest.php
index 67a5205..c82beed 100644
--- a/tests/Integration/Index/DocContractTest.php
+++ b/tests/Integration/Index/DocContractTest.php
@@ -110,4 +110,13 @@ public function testRefreshOption(): void
// refresh=wait_for makes it searchable immediately
$this->assertSame('refreshed', $index->newDoc('42')->source()['title']);
}
+
+ public function testRefreshAcceptsBool(): void
+ {
+ $index = $this->makeIndex();
+ // bool refresh (true/false) is valid per ES alongside 'wait_for';
+ // verify the widened string|bool type is accepted and the write lands.
+ $index->newDoc('43')->refresh(true)->index(['title' => 'bool-refresh']);
+ $this->assertSame('bool-refresh', $index->newDoc('43')->source()['title']);
+ }
}
diff --git a/tests/Integration/Index/RebuildContractTest.php b/tests/Integration/Index/RebuildContractTest.php
index 53ac043..93cfcb7 100644
--- a/tests/Integration/Index/RebuildContractTest.php
+++ b/tests/Integration/Index/RebuildContractTest.php
@@ -161,4 +161,73 @@ public function testAllowEmpty(): void
$this->assertNotEmpty($result['newIndex']);
$this->assertNull($result['oldIndex']);
}
+
+ public function testImportFailureDeletesNewIndex(): void
+ {
+ $alias = 'ek_rebuild_' . bin2hex(random_bytes(4));
+ $newIndex = $alias . '_backing_' . bin2hex(random_bytes(2));
+ $index = new class ($alias, $newIndex) extends Index {
+ public function __construct(string $alias, private string $backing)
+ {
+ $this->name = $alias;
+ }
+
+ public function rebuildName(): string
+ {
+ return $this->backing;
+ }
+
+ public function source(array $context = []): iterable
+ {
+ yield 1 => ['title' => 'A'];
+ throw new \RuntimeException('import blew up');
+ }
+ };
+
+ try {
+ (new Rebuild($index))->run();
+ $this->fail('Expected rebuild to throw on import failure');
+ } catch (\RuntimeException $e) {
+ $this->assertSame('import blew up', $e->getMessage());
+ }
+
+ $this->assertFalse(
+ $index->getClient()->indices()->exists(['index' => $newIndex])->asBool(),
+ 'Orphan new backing index must be deleted after an import failure'
+ );
+ }
+
+ public function testSwapFailureDeletesNewIndex(): void
+ {
+ // $this->indexName is a real index (created by setUp), not an alias
+ $newIndex = $this->indexName . '_backing_' . bin2hex(random_bytes(2));
+ $index = new class ($this->indexName, $newIndex) extends Index {
+ public function __construct(string $name, private string $backing)
+ {
+ $this->name = $name;
+ }
+
+ public function rebuildName(): string
+ {
+ return $this->backing;
+ }
+
+ public function source(array $context = []): iterable
+ {
+ yield 1 => ['title' => 'A']; // import succeeds; the swap then fails
+ }
+ };
+
+ try {
+ (new Rebuild($index))->run();
+ $this->fail('Expected rebuild to throw on a real index');
+ } catch (\RuntimeException $e) {
+ $this->assertStringContainsString('is a real index', $e->getMessage());
+ }
+
+ $this->assertFalse(
+ $index->getClient()->indices()->exists(['index' => $newIndex])->asBool(),
+ 'Orphan new backing index must be deleted after a swap failure'
+ );
+ }
}
diff --git a/tests/Integration/Index/SearchContractTest.php b/tests/Integration/Index/SearchContractTest.php
index b008896..bfa2799 100644
--- a/tests/Integration/Index/SearchContractTest.php
+++ b/tests/Integration/Index/SearchContractTest.php
@@ -52,9 +52,28 @@ public function testPaginateLastPage(): void
public function testScroll(): void
{
- $results = $this->makeIndex()->newQuery()->matchAll()->scroll(null, '1m');
+ $search = $this->makeIndex()->newQuery()->matchAll();
+ $results = $search->scroll(null, '1m');
$this->assertNotEmpty($results->scrollId());
- $this->assertGreaterThanOrEqual(1, count($results->hits()));
+ $this->assertCount(3, $results->hits());
+ $search->clear($results);
+ }
+
+ public function testScrollNextAndClear(): void
+ {
+ $search = $this->makeIndex()->newQuery()->matchAll()->size(2);
+
+ $first = $search->scroll(null, '1m');
+ $this->assertNotEmpty($first->scrollId());
+ $this->assertCount(2, $first->hits());
+
+ $second = $search->next($first, '1m');
+ $this->assertCount(1, $second->hits());
+
+ $third = $search->next($second, '1m');
+ $this->assertCount(0, $third->hits());
+
+ $search->clear($third);
}
public function testChunk(): void