diff --git a/README.md b/README.md
index 029041f..dbb5ea8 100644
--- a/README.md
+++ b/README.md
@@ -206,6 +206,12 @@ DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_PORT=5432 DB_DATABASE=testing DB_USERNA
CI runs the same two commands against `mysql:8` and `postgres:16` service containers; see `.github/workflows/integration-tests.yml`.
+## AI guidelines (Laravel Boost)
+
+This package ships AI guidelines for [Laravel Boost](https://github.com/laravel/boost), at the path the Boost documentation gives package authors: `resources/boost/guidelines/core.blade.php`. It is a short, always in context brief that tells an agent to reach for `LikeOperator`, `YearExpression` and `CsvValues` instead of writing `LIKE '%...%'`, `YEAR(...)` or `explode(',', ...)` by hand, and which drivers are supported.
+
+Boost finds the guidelines by looking for a `resources/boost` folder in the packages your application requires directly, so the missing service provider makes no difference. Install Boost 2.5 or higher with `composer require laravel/boost --dev` then `php artisan boost:install`, selecting this package when Boost asks which third party guidelines to install. On an app that already has Boost installed, `php artisan boost:update --discover` offers the newly detected package. The package has to be a direct requirement of the application (listed in its `composer.json`), because Boost ignores guidelines from transitive dependencies.
+
## Changelog
Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.
diff --git a/composer.json b/composer.json
index 47ef975..36cd299 100644
--- a/composer.json
+++ b/composer.json
@@ -32,6 +32,9 @@
"phpstan/phpstan-phpunit": "^2.0",
"rector/rector": "^2.0"
},
+ "suggest": {
+ "laravel/boost": "2.5 or higher reads the AI guidelines shipped in resources/boost"
+ },
"autoload": {
"psr-4": {
"PlinCode\\SqlDialect\\": "src/"
diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php
new file mode 100644
index 0000000..a375d40
--- /dev/null
+++ b/resources/boost/guidelines/core.blade.php
@@ -0,0 +1,48 @@
+## Laravel SQL Dialect
+
+Static helpers in `PlinCode\SqlDialect` for SQL that must behave the same on PostgreSQL, MySQL and SQLite. There is no service provider, config, facade or macro: import the class and call it. Use them instead of writing the driver specific SQL by hand.
+
+### Partial text matches
+
+Do not write `->where('title', 'like', "%{$term}%")` with a user supplied term: `%`, `_` and `\` in the term act as wildcards, and case sensitivity differs per driver. `LikeOperator::applyContains()` escapes the term, binds it, adds the right `ESCAPE` clause, and uses `ILIKE` on PostgreSQL and `LIKE` elsewhere.
+
+@verbatim
+
+use Illuminate\Database\Eloquent\Builder;
+use PlinCode\SqlDialect\LikeOperator;
+
+Movie::query()
+ ->where(function (Builder $query) use ($term, $excluded) {
+ LikeOperator::applyContains($query, 'title', $term);
+ LikeOperator::applyNotContains($query, 'title', $excluded);
+ })
+ ->get();
+
+@endverbatim
+
+- Variants: `LikeOperator::orApplyContains()`, `LikeOperator::applyNotContains()`, `LikeOperator::orApplyNotContains()`. Wrap OR clauses in a closure so they do not leak into the rest of the query. A `NULL` column matches neither the positive nor the negated form.
+- `LikeOperator::applyContainsOnDate()` matches a partial date by casting the column to text first, instead of a hand written `CAST(... AS CHAR)` or `::text`.
+- These methods take `Illuminate\Database\Eloquent\Builder` only, not `Illuminate\Database\Query\Builder` (so not inside `whereExists()` or `orWhereIn()` closures).
+- `$column` is interpolated into raw SQL: pass a column name from your code, never request input. The term is always bound.
+- Building SQL by hand: `LikeOperator::for()` returns the operator, `LikeOperator::containsPattern()` the escaped `%term%` pattern.
+
+### Year of a date column
+
+Do not write `YEAR(...)`, `EXTRACT(YEAR FROM ...)` or `strftime('%Y', ...)` in `whereRaw()` or `orderByRaw()`. Use `YearExpression::numeric()` (integer, for ordering and comparisons) or `YearExpression::text()` (text, for display).
+
+@verbatim
+
+use PlinCode\SqlDialect\YearExpression;
+
+$query->whereRaw(YearExpression::numeric($query, 'release_date').' = ?', [$year]);
+$query->orderByRaw(YearExpression::numeric($query, 'release_date').' desc');
+
+@endverbatim
+
+### Multi value filters
+
+Instead of `explode(',', ...)` plus `trim()` and `array_filter()`, call `CsvValues::parse($value)`. It accepts an array, a comma separated string or a scalar and returns a `list`: trimmed, blanks dropped, order kept. Non scalar entries are dropped silently.
+
+### Drivers
+
+Supported: `pgsql`, `mysql`, `mariadb` (handled but not covered by the test suite), `sqlite`. Any other driver, `sqlsrv` included, is unsupported and raises no error up front: `YearExpression::numeric()`, `YearExpression::text()` and `LikeOperator::applyContainsOnDate()` fall back to syntax that fails at the database at query time.
diff --git a/tests/Unit/BoostResourcesTest.php b/tests/Unit/BoostResourcesTest.php
new file mode 100644
index 0000000..f708c5a
--- /dev/null
+++ b/tests/Unit/BoostResourcesTest.php
@@ -0,0 +1,280 @@
+
+ */
+function boostComposer(): array
+{
+ /** @var array */
+ return json_decode((string) file_get_contents(__DIR__.'/../../composer.json'), true, flags: JSON_THROW_ON_ERROR);
+}
+
+function boostPackageNamespace(): string
+{
+ /** @var array{autoload: array{psr-4: array}} $composer */
+ $composer = boostComposer();
+
+ return rtrim((string) array_key_first($composer['autoload']['psr-4']), '\\');
+}
+
+/**
+ * The bodies of the code-snippet blocks, where calls carry real arguments.
+ *
+ * @return list
+ */
+function boostSnippets(string $rendered): array
+{
+ preg_match_all('/]*>(.*?)<\/code-snippet>/s', $rendered, $matches);
+
+ return $matches[1];
+}
+
+/**
+ * Maps the short names imported by `use` lines in the snippets to their FQCN.
+ *
+ * @return array
+ */
+function boostImports(string $rendered): array
+{
+ preg_match_all('/^use\s+([\w\\\\]+)(?:\s+as\s+(\w+))?;/m', $rendered, $matches, PREG_SET_ORDER);
+
+ $imports = [];
+
+ foreach ($matches as $match) {
+ $alias = ($match[2] ?? '') !== '' ? $match[2] : class_basename($match[1]);
+ $imports[$alias] = $match[1];
+ }
+
+ return $imports;
+}
+
+/**
+ * Resolves a short class name the way a reader would: an import first,
+ * then the package namespace. Null for a declared example class.
+ */
+function boostResolveClass(string $name, string $rendered): ?string
+{
+ if (in_array($name, BOOST_EXAMPLE_CLASSES, true)) {
+ return null;
+ }
+
+ return boostImports($rendered)[$name] ?? boostPackageNamespace().'\\'.$name;
+}
+
+/**
+ * Splits the argument list starting right after an opening parenthesis
+ * into top level arguments, honouring nesting and quoted strings.
+ *
+ * @return list
+ */
+function boostArguments(string $source, int $offset): array
+{
+ $arguments = [];
+ $current = '';
+ $depth = 0;
+ $quote = null;
+ $length = strlen($source);
+
+ for ($i = $offset; $i < $length; $i++) {
+ $char = $source[$i];
+
+ if ($quote !== null) {
+ $current .= $char;
+
+ if ($char === '\\') {
+ $current .= $source[++$i] ?? '';
+ } elseif ($char === $quote) {
+ $quote = null;
+ }
+
+ continue;
+ }
+
+ if ($char === '"' || $char === "'") {
+ $quote = $char;
+ } elseif (in_array($char, ['(', '[', '{'], true)) {
+ $depth++;
+ } elseif (in_array($char, [')', ']', '}'], true)) {
+ if ($depth === 0) {
+ break;
+ }
+
+ $depth--;
+ } elseif ($char === ',' && $depth === 0) {
+ $arguments[] = trim($current);
+ $current = '';
+
+ continue;
+ }
+
+ $current .= $char;
+ }
+
+ if (trim($current) !== '') {
+ $arguments[] = trim($current);
+ }
+
+ return $arguments;
+}
+
+describe('Boost guidelines', function (): void {
+ it('ships a single core guidelines file', function (): void {
+ expect(glob(__DIR__.'/../../resources/boost/guidelines/*'))
+ ->toBe([__DIR__.'/../../resources/boost/guidelines/core.blade.php']);
+ });
+
+ it('renders as blade without leaving directives behind', function (): void {
+ expect(boostGuideline())->not->toContain('@verbatim')
+ ->not->toContain('@endverbatim')
+ ->not->toContain('{{');
+ });
+
+ it('wraps its code examples in code-snippet tags', function (): void {
+ expect(boostGuideline())->toContain('toContain('');
+ });
+
+ it('keeps the guidelines short enough to stay in context', function (): void {
+ expect(strlen(boostGuideline()))->toBeLessThan(6000);
+ });
+
+ it('suggests laravel boost without requiring it', function (): void {
+ $composer = boostComposer();
+
+ expect($composer['suggest']['laravel/boost'] ?? null)->toBeString()->not->toBe('')
+ ->and($composer['require']['laravel/boost'] ?? null)->toBeNull()
+ ->and($composer['require-dev']['laravel/boost'] ?? null)->toBeNull();
+ });
+});
+
+describe('Boost guidelines drift', function (): void {
+ it('names only classes and namespaces that exist', function (): void {
+ preg_match_all('/\b[A-Z]\w*(?:\\\\[A-Z]\w*)+\b/', boostGuideline(), $matches);
+
+ $names = array_values(array_unique($matches[0]));
+
+ expect($names)->not->toBeEmpty();
+
+ foreach ($names as $name) {
+ expect(class_exists($name) || $name === boostPackageNamespace())
+ ->toBeTrue("The guideline names {$name}, which is neither a class nor the package namespace.");
+ }
+ });
+
+ it('calls only static methods that exist', function (): void {
+ $rendered = boostGuideline();
+
+ preg_match_all('/\b([A-Z]\w*)::(\w+)\(/', $rendered, $matches, PREG_SET_ORDER);
+
+ expect($matches)->not->toBeEmpty();
+
+ foreach ($matches as [, $class, $method]) {
+ $fqcn = boostResolveClass($class, $rendered);
+
+ if ($fqcn === null) {
+ continue;
+ }
+
+ expect(class_exists($fqcn))->toBeTrue("The guideline uses {$class}::{$method}(), but {$fqcn} does not exist.")
+ ->and(method_exists($fqcn, $method))->toBeTrue("The guideline uses {$class}::{$method}(), which {$fqcn} does not declare.");
+
+ expect(new ReflectionMethod($fqcn, $method)->isPublic())
+ ->toBeTrue("The guideline uses {$class}::{$method}(), which is not public.");
+ }
+ });
+
+ it('passes arguments that match the signature in every snippet call', function (): void {
+ $rendered = boostGuideline();
+ $checked = 0;
+
+ foreach (boostSnippets($rendered) as $snippet) {
+ preg_match_all('/\b([A-Z]\w*)::(\w+)\(/', $snippet, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
+
+ foreach ($matches as $match) {
+ [$call, $offset] = $match[0];
+ $fqcn = boostResolveClass($match[1][0], $rendered);
+
+ if ($fqcn === null || ! method_exists($fqcn, $match[2][0])) {
+ continue;
+ }
+
+ $reflection = new ReflectionMethod($fqcn, $match[2][0]);
+ $parameters = array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $reflection->getParameters());
+ $arguments = boostArguments($snippet, $offset + strlen($call));
+
+ expect(count($arguments))->toBeGreaterThanOrEqual($reflection->getNumberOfRequiredParameters(), "{$call}) passes too few arguments.")
+ ->toBeLessThanOrEqual($reflection->getNumberOfParameters(), "{$call}) passes too many arguments.");
+
+ foreach ($arguments as $argument) {
+ if (preg_match('/^(\w+):(?!:)/', $argument, $named) === 1) {
+ expect($parameters)->toContain($named[1]);
+ }
+ }
+
+ $checked++;
+ }
+ }
+
+ expect($checked)->toBeGreaterThan(0);
+ });
+
+ it('chains only builder methods that exist', function (): void {
+ preg_match_all('/->(\w+)\(/', boostGuideline(), $matches);
+
+ $methods = array_values(array_unique($matches[1]));
+
+ expect($methods)->not->toBeEmpty();
+
+ foreach ($methods as $method) {
+ expect(method_exists(EloquentBuilder::class, $method) || method_exists(QueryBuilder::class, $method))
+ ->toBeTrue("The guideline chains ->{$method}(), which neither builder declares.");
+ }
+ });
+
+ it('lists exactly the drivers the package branches on as supported', function (): void {
+ $drivers = [];
+
+ foreach (glob(__DIR__.'/../../src/*.php') ?: [] as $file) {
+ $source = (string) file_get_contents($file);
+
+ preg_match_all('/match\s*\([^{]*?\)\s*\{(.*?)^\s*\};/ms', $source, $blocks);
+
+ foreach ($blocks[1] as $block) {
+ preg_match_all("/'(\\w+)'\\s*(?:,|=>)/", $block, $arms);
+ $drivers = [...$drivers, ...$arms[1]];
+ }
+
+ preg_match_all("/(?:getDriverName\\(\\)|\\\$driver)\\s*===\\s*'(\\w+)'/", $source, $comparisons);
+ $drivers = [...$drivers, ...$comparisons[1]];
+ }
+
+ $drivers = array_unique($drivers);
+ sort($drivers);
+
+ preg_match('/^Supported:([^.]*)\./m', boostGuideline(), $line);
+ preg_match_all('/`(\w+)`/', $line[1] ?? '', $listed);
+ $listed = array_unique($listed[1]);
+ sort($listed);
+
+ expect($drivers)->toContain('pgsql', 'mysql', 'sqlite')
+ ->and($listed)->toBe($drivers);
+ });
+});