diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2e7b8ca..21d5661 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
+### Added
+
+- Laravel Boost resources: a short guideline in `resources/boost/guidelines/core.blade.php`
+ and an on demand `custom-fields-development` skill in `resources/boost/skills`, so an
+ agent working in an application that uses the package knows the trait, the value API, the
+ query helpers, the config keys and how to add a field type. `laravel/boost` is suggested,
+ never required.
+- A test that parses both documents and checks every class, method, signature, config key,
+ field type, storage column, query operation, publish tag and artisan command they name
+ against the code, so the documents fail the build when the API changes under them.
+
## 0.1.1 - 2026-09-17
### Changed
diff --git a/README.md b/README.md
index fa1e99a..890962c 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,7 @@ authorization and no UI.
- [Building a form](#building-a-form)
- [Events](#events)
- [Custom field types](#custom-field-types)
+- [AI guidelines (Laravel Boost)](#ai-guidelines-laravel-boost)
- [Testing](#testing)
## Installation
@@ -752,6 +753,24 @@ The vocabulary of query operations is `equals`, `in`, `contains`, `greater_than`
A type may declare an operation outside that list, and then it is responsible for providing
the filter that serves it, since the built in filter refuses an operation it does not know.
+## AI guidelines (Laravel Boost)
+
+This package ships AI guidelines and an agent skill for [Laravel Boost](https://github.com/laravel/boost),
+at the paths the Boost documentation gives package authors:
+
+- `resources/boost/guidelines/core.blade.php`, a short always in context brief on the
+ trait, entity registration, field definitions, the value API, the query helpers and the
+ config keys.
+- `resources/boost/skills/custom-fields-development/SKILL.md`, loaded on demand, covering
+ installation, making a model support custom fields, defining fields of every type, adding
+ a custom field type, validating values in a controller, building a form, and filtering and
+ sorting a listing.
+
+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 and skills to install. On an app that already has Boost installed,
+`php artisan boost:update --discover` offers the newly detected package.
+
## Testing
```bash
diff --git a/composer.json b/composer.json
index 5198d9a..f3acdf1 100644
--- a/composer.json
+++ b/composer.json
@@ -44,6 +44,9 @@
"phpstan/phpstan-phpunit": "^2.0",
"rector/rector": "^2.6"
},
+ "suggest": {
+ "laravel/boost": "2.5 or higher reads the AI guidelines and agent skill shipped in resources/boost"
+ },
"autoload": {
"psr-4": {
"PlinCode\\CustomFields\\": "src/",
diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php
new file mode 100644
index 0000000..241d088
--- /dev/null
+++ b/resources/boost/guidelines/core.blade.php
@@ -0,0 +1,112 @@
+## Laravel Custom Fields
+
+`plin-code/laravel-custom-fields` lets a product define fields on Eloquent models at
+runtime. A definition is a `PlinCode\CustomFields\Models\CustomField` row, and every value
+is a `PlinCode\CustomFields\Models\CustomFieldValue` row holding the value in one typed
+column (`value_string`, `value_text`, `value_integer`, `value_decimal`, `value_boolean`,
+`value_date`, `value_datetime`, `value_json`). The package is headless: no routes, no
+controllers, no UI. Use the facade `PlinCode\CustomFields\Facades\CustomFields`.
+
+### Making a model own custom fields
+
+There is no interface to implement. Add the `PlinCode\CustomFields\Concerns\HasCustomFields`
+trait and register the model under a short entity key in the `boot()` method of a service
+provider, on every request, since the key also goes into the morph map.
+
+@verbatim
+
+use App\Models\Patient;
+use PlinCode\CustomFields\Facades\CustomFields;
+
+CustomFields::registerEntity(Patient::class, 'patient', 'Patient');
+
+@endverbatim
+
+### Defining fields
+
+Fill `entity_type` with `CustomFields::entityKey()`, never a typed string: nothing checks it,
+and a typo creates a field every read ignores. `type` is a registered type key: `text`,
+`textarea`, `email`, `url`, `phone`, `number`, `decimal`, `boolean`, `date`, `datetime`,
+`select`, `multiselect` (not `multi_select`). The slug is generated from the name on
+creation and never changes, it is the key used everywhere else.
+
+@verbatim
+
+use PlinCode\CustomFields\Models\CustomField;
+
+$field = CustomField::create([
+ 'entity_type' => CustomFields::entityKey(Patient::class),
+ 'name' => 'Risk level',
+ 'type' => 'select',
+ 'is_required' => true,
+ 'options' => [
+ ['key' => 'low', 'label' => 'Low', 'is_active' => true],
+ ['key' => 'high', 'label' => 'High', 'is_active' => true],
+ ],
+]);
+
+$field->slug; // 'risk-level'
+
+@endverbatim
+
+Option keys are permanent. Change options with `$field->updateOptions()`, deactivate an
+option with `is_active => false`, never remove one (it throws).
+
+### Reading and writing values
+
+@verbatim
+
+$patient->setCustomFields(['risk-level' => 'high', 'notes' => 'Follow up.']);
+$patient->setCustomFields($payload, complete: true);
+$patient->setCustomField('notes', null); // deletes the stored row
+$patient->getCustomField('risk-level'); // 'high'
+$patient->getCustomFields(); // every active field, keyed by slug
+$patient->clearCustomField('notes');
+
+@endverbatim
+
+- Writes validate the whole batch, then write it in one transaction. Partial validation
+ (the default) checks only the submitted keys; `complete: true` also enforces required
+ fields against the stored values.
+- An invalid value, an unknown slug or an inactive field throws
+ `Illuminate\Validation\ValidationException` keyed by slug.
+- Writing to an unsaved model throws `PlinCode\CustomFields\Exceptions\ModelNotPersistedException`.
+- `getCustomField()`, `clearCustomField()` and the `whereCustomField()` scope throw
+ `PlinCode\CustomFields\Exceptions\UnknownCustomFieldException` for an unknown slug.
+- Validate without writing with `CustomFields::validate($patient, $values, complete: true)`.
+
+### Querying
+
+`Patient::whereCustomField('risk-level', 'high')` is an equality scope on the stored shape.
+Request filters and sorts are built for `spatie/laravel-query-builder`, one per operation the
+field type declares. They read the definitions from the database, so call them while serving
+a request, never in a service provider.
+
+@verbatim
+
+use Spatie\QueryBuilder\QueryBuilder;
+
+$options = CustomFields::queryOptionsFor(Patient::class);
+
+QueryBuilder::for(Patient::class)
+ ->allowedFilters(...$options['filters'])
+ ->allowedSorts(...$options['sorts'])
+ ->paginate();
+
+@endverbatim
+
+Names are the prefix plus the slug, with `:operation` for anything but equality:
+`filter[cf_risk-level]=high`, `filter[cf_risk-level:in]=high,low`, `sort=-cf_risk-level`.
+Build them with `CustomFields::filterName()` and `CustomFields::sortName()`.
+
+### Configuration
+
+Read `config/laravel-custom-fields.php` before assuming table names or key types:
+
+- `laravel-custom-fields.tables.fields` and `laravel-custom-fields.tables.values`: table names.
+- `laravel-custom-fields.key_type` and `laravel-custom-fields.morph_key_type`: `id`, `uuid` or
+ `ulid`. Set them before running the migrations.
+- `laravel-custom-fields.models.custom_field` and `laravel-custom-fields.models.custom_field_value`:
+ replacement models, which must extend the package ones. Resolve them with
+ `CustomFields::fieldModel()` and `CustomFields::valueModel()`.
+- `laravel-custom-fields.key_prefix`: the filter and sort prefix, `cf_` by default.
diff --git a/resources/boost/skills/custom-fields-development/SKILL.md b/resources/boost/skills/custom-fields-development/SKILL.md
new file mode 100644
index 0000000..11ab566
--- /dev/null
+++ b/resources/boost/skills/custom-fields-development/SKILL.md
@@ -0,0 +1,306 @@
+---
+name: custom-fields-development
+description: Install plin-code/laravel-custom-fields, attach custom fields to an Eloquent model, define fields of every built in type, add a custom field type, and expose custom fields as validated values, request filters and sorts.
+---
+
+# Custom fields development
+
+## When to use this skill
+
+Use this skill when a task touches `plin-code/laravel-custom-fields`: installing it, letting
+a model own custom fields, creating or changing field definitions, reading or writing values
+from a controller or a form request, adding a field type of your own, or filtering and sorting
+a listing on custom fields. Every step below follows the package source, so prefer it over
+guessing method names.
+
+## Install and migrate
+
+1. Publish the config and decide the key types first, because both are baked into the
+ migrations:
+
+```bash
+php artisan vendor:publish --tag="laravel-custom-fields-config"
+```
+
+2. In `config/laravel-custom-fields.php` set `key_type` (keys of the two package tables) and
+ `morph_key_type` (keys of the models that own fields, stored in `valuable_id`). Each accepts
+ `id`, `uuid` or `ulid`. Use `morph_key_type => 'uuid'` or `'ulid'` only when every model
+ that will own fields uses that key type. An unsupported value throws an
+ `InvalidArgumentException` while the service provider registers.
+3. Publish and run the two migrations (`create_custom_fields_table`,
+ `create_custom_field_values_table`):
+
+```bash
+php artisan vendor:publish --tag="laravel-custom-fields-migrations"
+php artisan migrate
+```
+
+Changing a key type after this point needs a data migration written by hand. The umbrella tag
+`laravel-custom-fields` publishes config, migrations and translations at once, and
+`laravel-custom-fields-lang` publishes only the English and Italian messages.
+
+## Make a model support custom fields
+
+1. Add the trait to the model. No interface and no extra column are needed on the host table,
+ values live in the package table through the polymorphic `valuable` relation.
+
+```php
+use Illuminate\Database\Eloquent\Model;
+use PlinCode\CustomFields\Concerns\HasCustomFields;
+
+class Patient extends Model
+{
+ use HasCustomFields;
+}
+```
+
+2. Register the entity in `boot()` of a service provider, so it runs on every request:
+
+```php
+use PlinCode\CustomFields\Facades\CustomFields;
+
+public function boot(): void
+{
+ CustomFields::registerEntity(Patient::class, 'patient', 'Patient');
+}
+```
+
+The signature is `registerEntity(string $model, string $key, ?string $label = null)`. The key
+is stored in `entity_type` and added to the morph map. Registering the same model twice is
+ignored, while reusing a key for another model throws `InvalidArgumentException`.
+`CustomFields::entities()` returns the registry and `CustomFields::entityKey($modelOrClass)`
+resolves a key, throwing `InvalidArgumentException` for an unregistered model.
+
+3. In a test, register the entity in `beforeEach` (or `setUp`), since the registry lives on a
+ singleton that starts empty.
+
+## Define fields of each type
+
+A definition has these columns: `entity_type`, `name` (trimmed and lowercased, unique per
+entity), `slug` (generated from the name, at most 100 characters, immutable), `type`,
+`options`, `is_required`, `is_active`, `sort_order`.
+
+| `type` | Storage column | Accepts | Query operations (`sort` means it can be sorted on) |
+| --- | --- | --- | --- |
+| `text`, `phone` | `value_string` | string up to 255 | `equals`, `in`, `contains`, `is_null`, `is_not_null`, `sort` |
+| `email` | `value_string` | email up to 255 | same as `text` |
+| `url` | `value_string` | URL up to 255 | same as `text` |
+| `textarea` | `value_text` | any string | `equals`, `contains`, `is_null`, `is_not_null` |
+| `number` | `value_integer` | integer | `equals`, `in`, `greater_than`, `less_than`, `between`, `is_null`, `is_not_null`, `sort` |
+| `decimal` | `value_decimal` | numeric, read back as a trimmed string | same as `number` |
+| `boolean` | `value_boolean` | boolean | `equals`, `is_null`, `is_not_null`, `sort` |
+| `date` | `value_date` | date | `equals`, `greater_than`, `less_than`, `between`, `is_null`, `is_not_null`, `sort` |
+| `datetime` | `value_datetime` | date | same as `date` |
+| `select` | `value_string` | one assignable option key | `equals`, `in`, `is_null`, `is_not_null`, `sort` |
+| `multiselect` | `value_json` | array of assignable option keys | `contains_any`, `contains_all`, `is_null`, `is_not_null` |
+
+Every type reads back `null` when nothing is stored, except `multiselect`, which reads `[]`.
+
+```php
+use PlinCode\CustomFields\Facades\CustomFields;
+use PlinCode\CustomFields\Models\CustomField;
+
+$entity = CustomFields::entityKey(Patient::class);
+
+CustomField::create(['entity_type' => $entity, 'name' => 'Date of birth', 'type' => 'date']);
+CustomField::create(['entity_type' => $entity, 'name' => 'Weight', 'type' => 'decimal', 'sort_order' => 20]);
+CustomField::create(['entity_type' => $entity, 'name' => 'Allergies', 'type' => 'multiselect', 'options' => [
+ ['key' => 'pollen', 'label' => 'Pollen', 'is_active' => true],
+ ['key' => 'latex', 'label' => 'Latex', 'is_active' => true],
+]]);
+```
+
+Rules the definition observer enforces, each failing with `InvalidArgumentException`:
+
+- an empty name, an option without `key` or `label`, a duplicated option key;
+- changing `slug` or `entity_type` after creation;
+- changing `type` while values exist;
+- removing an option key, whether through `update()` or `$field->updateOptions($options)`.
+
+Two definitions of one entity with the same name hit a unique index and surface as
+`Illuminate\Database\UniqueConstraintViolationException`. To retire an option set its
+`is_active` to `false`: records that already hold it keep it, new records cannot pick it.
+`$field->optionsForInput()` returns the active options, `$field->activeOptionKeys()` their keys
+and `$field->optionKeys()` every key. To retire a whole field set `is_active` to `false` on the
+definition: it disappears from reads, writes, filters and sorts, its stored values survive and
+`getCustomFields(includeInactive: true)` still reads them.
+
+## Read and write values in a controller
+
+Signatures on the trait:
+
+- `getCustomField(string $slug, bool $includeInactive = false): mixed`
+- `getCustomFields(bool $includeInactive = false): array`
+- `setCustomField(string $slug, mixed $value): void`
+- `setCustomFields(array $values, bool $complete = false): void`
+- `clearCustomField(string $slug): void`
+- `customFieldValues(): MorphMany`
+
+A form posts values keyed by slug. Hand the array straight to the model:
+
+```php
+use Illuminate\Http\Request;
+
+public function update(Request $request, Patient $patient)
+{
+ $patient->setCustomFields($request->input('custom_fields', []), complete: true);
+
+ return $patient->getCustomFields();
+}
+```
+
+A failed batch throws `ValidationException`, which Laravel turns into a 422 with errors keyed
+by slug, and nothing is written. To validate earlier, for example in a form request, call
+`CustomFields::validate($patient, $values, complete: true)`. To merge the type rules into your
+own rule array use `CustomFields::validator()->rules(Patient::class, complete: true)`, but keep
+the write path, because that array does not reject unknown slugs, inactive fields or retired
+option keys.
+
+`null` deletes the stored row. On a `multiselect` an empty array is a stored answer and keeps
+the row. Write a `date` as `Y-m-d` and a `datetime` as `Y-m-d H:i:s`, they read back as written.
+
+## Build a form
+
+The package renders nothing. Build the input list from the type registry and the definitions:
+
+```php
+$fieldModel = CustomFields::fieldModel();
+
+$fields = $fieldModel::query()
+ ->where('entity_type', CustomFields::entityKey(Patient::class))
+ ->where('is_active', true)
+ ->orderBy('sort_order')
+ ->get()
+ ->map(fn ($field) => [
+ 'name' => $field->slug,
+ 'label' => $field->name,
+ 'required' => $field->is_required,
+ 'input' => $field->fieldType()->inputHint(),
+ 'options' => $field->optionsForInput(),
+ 'rules' => $field->fieldType()->rules($field),
+ ]);
+```
+
+For the admin screen that creates definitions, `CustomFields::types()` returns every
+registered type keyed by its key, each exposing `label()` and `inputHint()`. There is no
+Filament or Livewire integration in the package: map these arrays onto your own components.
+
+## Add a custom field type
+
+1. Implement `PlinCode\CustomFields\Contracts\FieldType`, or extend a built in type such as
+ `PlinCode\CustomFields\Types\TextType` and override only what differs. The contract:
+
+```php
+public static function key(): string;
+public function storageColumn(): string;
+public function serialize(mixed $value, Model $field): mixed;
+public function deserialize(mixed $value, Model $field): mixed;
+public function rules(Model $field): array;
+public function default(): mixed;
+public function label(): string;
+public function inputHint(): string;
+public function queryOperations(): array;
+```
+
+2. Pick `storageColumn()` from the eight existing columns. The values table has no other
+ column, so a new name breaks every write.
+3. `serialize()` turns input into the stored shape and is also applied to filter values, so
+ filtering compares the same thing a write stores. `deserialize()` turns the stored shape
+ into what reads return, and may differ from what `rules()` accepts.
+4. Declare in `queryOperations()` only operations that work on the chosen column, using
+ `equals`, `in`, `contains`, `greater_than`, `less_than`, `between`, `is_null`, `is_not_null`,
+ `contains_any`, `contains_all` and `sort`.
+
+```php
+use Illuminate\Database\Eloquent\Model;
+use PlinCode\CustomFields\Types\TextType;
+
+class CountryType extends TextType
+{
+ public static function key(): string
+ {
+ return 'country';
+ }
+
+ public function rules(Model $field): array
+ {
+ return ['nullable', 'string', 'size:2'];
+ }
+
+ public function label(): string
+ {
+ return 'Country';
+ }
+
+ public function queryOperations(): array
+ {
+ return ['equals', 'in', 'is_null', 'is_not_null', 'sort'];
+ }
+}
+```
+
+5. Register it in `boot()` next to the entities with `CustomFields::registerType(CountryType::class)`.
+ A key registered twice throws `InvalidArgumentException`, so a built in key cannot be
+ replaced, choose a new one. Nothing checks `type` when a definition is created: a key that
+ is not registered throws `InvalidArgumentException` from `CustomFields::type()` at the first
+ read or write of that field.
+6. An operation outside the vocabulary is allowed, but `CustomFields::filtersFor()` skips it
+ (`PlinCode\CustomFields\Query\CustomFieldFilter::supports()` returns `false`) and the
+ `CustomFieldFilter` constructor refuses it. Serve it with your own
+ `Spatie\QueryBuilder\Filters\Filter` registered under the package name:
+
+```php
+use Spatie\QueryBuilder\AllowedFilter;
+
+AllowedFilter::custom(CustomFields::filterName($slug, 'within_reach'), new WithinReachFilter($field));
+```
+
+## Filter and sort a listing
+
+1. Make sure `spatie/laravel-query-builder` drives the listing. Spread the package output into
+ `allowedFilters()` and `allowedSorts()`. Use `CustomFields::queryOptionsFor()` when you need
+ both, it reads the definitions once instead of twice; `CustomFields::filtersFor()` and
+ `CustomFields::sortsFor()` return one list each.
+2. Call them inside the request, never in `register()` or `boot()`.
+3. Compose with your own filters and sorts, including the sorts of
+ `plin-code/laravel-eloquent-sorts`:
+
+```php
+use Spatie\QueryBuilder\AllowedSort;
+use Spatie\QueryBuilder\QueryBuilder;
+
+$options = CustomFields::queryOptionsFor(Patient::class);
+
+QueryBuilder::for(Patient::class)
+ ->allowedFilters('name', ...$options['filters'])
+ ->allowedSorts(AllowedSort::field('last_name'), ...$options['sorts'])
+ ->paginate();
+```
+
+4. Tell the client the names, built from `key_prefix` and the slug. Equality is
+ `filter[cf_slug]`, every other operation appends a colon and its name, as in
+ `filter[cf_slug:between]`, and sorts are `sort=cf_slug` and `sort=-cf_slug`. Values:
+ `in`, `contains_any`, `contains_all` take a comma separated list, `between` exactly two
+ values, `greater_than` and `less_than` one value, `is_null` and `is_not_null` take `1` or
+ `0`. A `multiselect` has no bare equality filter, so `filter[cf_allergies]=latex` is
+ rejected with Spatie's `InvalidFilterQuery`; send `filter[cf_allergies:contains_any]=latex`.
+ Records without a value sort last in both directions.
+5. Constants for the operation names live on `CustomFieldFilter` (`CustomFieldFilter::BETWEEN`,
+ `CustomFieldFilter::CONTAINS_ANY`) and `PlinCode\CustomFields\Query\CustomFieldSorter::SORT`.
+
+Outside a request, use the scope: `Patient::whereCustomField('allergies', 'latex')` matches by
+JSON containment on a `multiselect` and by equality elsewhere.
+
+## Replace the package models
+
+To add a global scope (a tenant, for example), a connection or relations, extend
+`PlinCode\CustomFields\Models\CustomField` or `PlinCode\CustomFields\Models\CustomFieldValue`
+and point `models.custom_field` or `models.custom_field_value` in the config at the subclass.
+In application code resolve the classes with `CustomFields::fieldModel()` and
+`CustomFields::valueModel()`, so the configured subclass and its scopes always apply.
+
+## React to changes
+
+Listen for `PlinCode\CustomFields\Events\CustomFieldCreated`, `CustomFieldUpdated`,
+`CustomFieldDeleted` (property `$field`) and `CustomFieldValueSaved`, `CustomFieldValueDeleted`
+(property `$value`). All of them are dispatched after the surrounding transaction commits.
diff --git a/tests/Unit/BoostResourcesTest.php b/tests/Unit/BoostResourcesTest.php
new file mode 100644
index 0000000..c8f8a18
--- /dev/null
+++ b/tests/Unit/BoostResourcesTest.php
@@ -0,0 +1,510 @@
+ */
+function boostDocuments(): array
+{
+ return ['guideline' => boostGuideline(), 'skill' => boostSkill()];
+}
+
+/**
+ * Every package or framework class the documents name by its fully qualified name,
+ * plus the ones their code examples import, keyed by short name.
+ *
+ * @return array
+ */
+function boostKnownClasses(string $text): array
+{
+ preg_match_all('/(?
+ */
+function boostExampleClasses(string $text, array $known): array
+{
+ $examples = [];
+
+ preg_match_all('/use App\\\\[\w\\\\]*\\\\(\w+);/', $text, $imports);
+
+ foreach ($imports[1] as $class) {
+ $examples[$class] = Model::class;
+ }
+
+ preg_match_all('/class (\w+) extends (\w+)/', $text, $declarations, PREG_SET_ORDER);
+
+ foreach ($declarations as [, $class, $parent]) {
+ $examples[$class] = $known[$parent] ?? null;
+ }
+
+ return $examples;
+}
+
+/**
+ * Where a method named in the documents may live when the receiver is not spelled out.
+ *
+ * @param array $known
+ * @return array
+ */
+function boostMethodHosts(array $known): array
+{
+ return array_values(array_unique([
+ ...array_values($known),
+ PlinCode\CustomFields\CustomFields::class,
+ HasCustomFields::class,
+ Model::class,
+ Builder::class,
+ QueryBuilder::class,
+ Collection::class,
+ EloquentCollection::class,
+ ServiceProvider::class,
+ ]));
+}
+
+function boostMethodExists(string $class, string $method): bool
+{
+ if (method_exists($class, $method)) {
+ return true;
+ }
+
+ if (is_subclass_of($class, Facade::class)) {
+ return method_exists($class::getFacadeRoot(), $method);
+ }
+
+ // Eloquent forwards static and instance calls it does not define to the builder,
+ // and a scope is reached without its prefix.
+ if (is_a($class, Model::class, true) || $class === HasCustomFields::class) {
+ return method_exists(Builder::class, $method)
+ || method_exists(QueryBuilder::class, $method)
+ || method_exists($class, 'scope'.ucfirst($method))
+ || method_exists(HasCustomFields::class, 'scope'.ucfirst($method));
+ }
+
+ return false;
+}
+
+/** Renders a method signature the way the documents write it, with short type names. */
+function boostSignature(ReflectionMethod $method, bool $withReturn = true): string
+{
+ $type = static fn (?ReflectionType $type): string => $type instanceof ReflectionType ? preg_replace_callback(
+ '/[\w\\\\]+/',
+ static fn (array $match): string => class_basename($match[0]),
+ (string) $type,
+ ) : '';
+
+ $parameters = array_map(static function (ReflectionParameter $parameter) use ($type): string {
+ $signature = trim($type($parameter->getType()).' $'.$parameter->getName());
+
+ if ($parameter->isDefaultValueAvailable()) {
+ $signature .= ' = '.strtolower(var_export($parameter->getDefaultValue(), true));
+ }
+
+ return $signature;
+ }, $method->getParameters());
+
+ $signature = $method->getName().'('.implode(', ', $parameters).')';
+
+ return $withReturn ? $signature.': '.$type($method->getReturnType()) : $signature;
+}
+
+/** @return array */
+function boostStorageColumns(): array
+{
+ /** @var array $columns */
+ $columns = (new ReflectionClass(HasCustomFields::class))->getConstant('STORAGE_COLUMNS');
+
+ return $columns;
+}
+
+/** @return array */
+function boostQueryOperations(): array
+{
+ return [...CustomFieldFilter::operations(), CustomFieldSorter::SORT];
+}
+
+/**
+ * Type keys the examples declare themselves, such as a CountryType returning 'country'.
+ *
+ * @return array
+ */
+function boostDeclaredTypeKeys(string $text): array
+{
+ preg_match_all("/function key\(\): string\s*\{\s*return '(\w+)';/", $text, $matches);
+
+ return $matches[1];
+}
+
+describe('Boost guidelines', function (): void {
+ it('ships a single core guidelines file', function (): void {
+ expect(file_exists(BOOST_GUIDELINE))->toBeTrue();
+ });
+
+ it('renders as blade without leaving directives behind', function (): void {
+ expect(boostGuideline())->not->toContain('@verbatim')
+ ->not->toContain('@endverbatim')
+ ->not->toContain('{{');
+ });
+
+ it('names the trait, the registration and the main api', function (): void {
+ expect(boostGuideline())->toContain('HasCustomFields')
+ ->toContain('registerEntity')
+ ->toContain('setCustomFields')
+ ->toContain('getCustomField')
+ ->toContain('queryOptionsFor')
+ ->toContain('whereCustomField');
+ });
+
+ 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 = json_decode((string) file_get_contents(__DIR__.'/../../composer.json'), true);
+
+ 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 skill', function (): void {
+ it('ships a skill file in a folder named after the skill', function (): void {
+ expect(file_exists(BOOST_SKILL))->toBeTrue();
+ });
+
+ it('declares the frontmatter boost requires', function (): void {
+ $content = boostSkill();
+
+ expect($content)->toStartWith("---\n");
+
+ preg_match('/^---\s*\n(.*?)\n---\s*\n/s', $content, $matches);
+
+ $frontmatter = Yaml::parse($matches[1] ?? '');
+
+ expect($frontmatter)->toBeArray()
+ ->and($frontmatter['name'] ?? null)->toBe('custom-fields-development')
+ ->and($frontmatter['description'] ?? null)->toBeString()
+ ->and($frontmatter['description'] ?? '')->not->toBe('');
+ });
+
+ it('tells the agent when to use the skill and how to add a type', function (): void {
+ expect(boostSkill())->toContain('## When to use this skill')
+ ->toContain('registerType')
+ ->toContain('FieldType');
+ });
+
+ it('does not repeat the guidelines', function (): void {
+ $skill = boostSkill();
+
+ expect($skill)->not->toBe(boostGuideline());
+
+ preg_match_all('/]*>(.*?)<\/code-snippet>/s', boostGuideline(), $snippets);
+
+ foreach ($snippets[1] as $snippet) {
+ expect($skill)->not->toContain(trim($snippet));
+ }
+ });
+});
+
+/*
+ * The documents are read by agents that cannot tell a stale name from a real one, so
+ * every identifier they mention is extracted from the text and checked against the
+ * code. A rename, a removed method, a dropped config key or a changed signature fails
+ * here instead of misleading the next agent.
+ */
+describe('Boost drift', function (): void {
+ it('names only classes, interfaces and traits that exist', function (string $document): void {
+ foreach (boostKnownClasses(boostDocuments()[$document]) as $class) {
+ expect(class_exists($class) || interface_exists($class) || trait_exists($class))
+ ->toBeTrue("{$document} names [{$class}], which does not exist.");
+ }
+ })->with(['guideline', 'skill']);
+
+ it('calls only static methods and constants that exist', function (string $document): void {
+ $text = boostDocuments()[$document];
+ $known = boostKnownClasses(implode("\n", boostDocuments()));
+ $examples = boostExampleClasses($text, $known);
+
+ preg_match_all('/\b([A-Z]\w*)::(\w+)(\()?/', $text, $calls, PREG_SET_ORDER);
+
+ expect($calls)->not->toBeEmpty();
+
+ foreach ($calls as $call) {
+ [, $short, $member] = $call;
+ $isCall = isset($call[3]);
+
+ if ($member === 'class') {
+ continue;
+ }
+
+ if (array_key_exists($short, $examples) && ! isset($known[$short])) {
+ $parent = $examples[$short];
+
+ expect($parent !== null && boostMethodExists($parent, $member))
+ ->toBeTrue("{$document} calls [{$short}::{$member}()], which [{$parent}] does not provide.");
+
+ continue;
+ }
+
+ expect(isset($known[$short]))->toBeTrue("{$document} uses [{$short}::{$member}] without naming the class in full anywhere.");
+
+ $class = $known[$short];
+
+ if (! $isCall) {
+ expect(defined($class.'::'.$member))->toBeTrue("{$document} names the constant [{$class}::{$member}], which does not exist.");
+
+ continue;
+ }
+
+ expect(boostMethodExists($class, $member))->toBeTrue("{$document} calls [{$class}::{$member}()], which does not exist.");
+ }
+ })->with(['guideline', 'skill']);
+
+ it('calls only instance methods that exist', function (string $document): void {
+ $text = boostDocuments()[$document];
+ $hosts = boostMethodHosts(boostKnownClasses(implode("\n", boostDocuments())));
+
+ preg_match_all('/->(\w+)\(/', $text, $arrows);
+ preg_match_all('/`(?:\$\w+->)?([a-z]\w*)\(/', $text, $inline);
+
+ $methods = array_unique([...$arrows[1], ...$inline[1]]);
+
+ expect($methods)->not->toBeEmpty();
+
+ foreach ($methods as $method) {
+ $found = array_filter($hosts, static fn (string $host): bool => boostMethodExists($host, $method));
+
+ expect($found)->not->toBeEmpty("{$document} calls [{$method}()], which no referenced class provides.");
+ }
+ })->with(['guideline', 'skill']);
+
+ it('writes the trait signatures exactly as the code declares them', function (): void {
+ preg_match_all('/^- `(\w+\(.*?\): \w+)`$/m', boostSkill(), $matches);
+
+ expect($matches[1])->not->toBeEmpty();
+
+ foreach ($matches[1] as $documented) {
+ $name = strstr($documented, '(', true);
+
+ expect(boostSignature(new ReflectionMethod(HasCustomFields::class, (string) $name)))->toBe($documented);
+ }
+
+ preg_match('/`registerEntity\((.*?)\)`/', boostSkill(), $entity);
+
+ expect(boostSignature(new ReflectionMethod(PlinCode\CustomFields\CustomFields::class, 'registerEntity'), false))
+ ->toBe('registerEntity('.($entity[1] ?? '').')');
+ });
+
+ it('lists the whole field type contract and nothing else', function (): void {
+ preg_match_all('/^public (?:static )?function (\w+\(.*?\): \w+);$/m', boostSkill(), $matches);
+
+ $contract = array_map(
+ static fn (ReflectionMethod $method): string => boostSignature($method),
+ (new ReflectionClass(FieldType::class))->getMethods(),
+ );
+
+ expect($matches[1])->toEqualCanonicalizing($contract);
+ });
+
+ it('names only config keys the package config defines, and all of them', function (string $document): void {
+ $text = boostDocuments()[$document];
+ $config = (array) config('laravel-custom-fields');
+
+ preg_match_all('/(?toBeTrue("{$document} names the config key [{$key}], which does not exist.");
+ }
+
+ if ($document === 'guideline') {
+ foreach (array_keys(Arr::dot($config)) as $key) {
+ expect($text)->toContain('laravel-custom-fields.'.$key);
+ }
+ }
+ })->with(['guideline', 'skill']);
+
+ it('names only registered field types, and all of them', function (string $document): void {
+ $text = boostDocuments()[$document];
+ $registered = array_keys(CustomFields::types());
+ $declared = boostDeclaredTypeKeys($text);
+
+ preg_match_all("/'type' => '(\w+)'/", $text, $used);
+
+ foreach ($used[1] as $key) {
+ expect([...$registered, ...$declared])->toContain($key);
+ }
+
+ foreach ($registered as $key) {
+ expect($text)->toContain("`{$key}`");
+ }
+ })->with(['guideline', 'skill']);
+
+ it('describes the storage column and the operations of every type as the code does', function (): void {
+ preg_match_all('/^\| (`\w+`(?:, `\w+`)*) \| `(\w+)` \| [^|]+ \| ([^|]+) \|$/m', boostSkill(), $rows, PREG_SET_ORDER);
+
+ $documented = [];
+
+ foreach ($rows as [, $keys, $column, $operations]) {
+ preg_match_all('/`(\w+)`/', $keys, $typeKeys);
+ preg_match('/^same as `(\w+)`$/', trim($operations), $same);
+ preg_match_all('/`(\w+)`/', $operations, $listed);
+
+ foreach ($typeKeys[1] as $key) {
+ $documented[$key] = [
+ 'column' => $column,
+ 'operations' => $same === [] ? $listed[1] : $documented[$same[1]]['operations'],
+ ];
+ }
+ }
+
+ $actual = array_map(static fn (FieldType $type): array => [
+ 'column' => $type->storageColumn(),
+ 'operations' => $type->queryOperations(),
+ ], CustomFields::types());
+
+ expect(array_keys($documented))->toEqualCanonicalizing(array_keys($actual));
+
+ foreach ($actual as $key => $type) {
+ expect($documented[$key]['column'])->toBe($type['column'], "storage column of [{$key}]")
+ ->and($documented[$key]['operations'])->toEqualCanonicalizing($type['operations']);
+ }
+ });
+
+ it('names only storage columns the values table has, and the guideline names all of them', function (string $document): void {
+ $text = boostDocuments()[$document];
+
+ preg_match_all('/\bvalue_[a-z]+\b/', $text, $columns);
+
+ foreach (array_unique($columns[0]) as $column) {
+ expect(boostStorageColumns())->toContain($column);
+ }
+
+ if ($document === 'guideline') {
+ foreach (boostStorageColumns() as $column) {
+ expect($text)->toContain("`{$column}`");
+ }
+ }
+ })->with(['guideline', 'skill']);
+
+ it('names only query operations the package serves', function (string $document): void {
+ $text = boostDocuments()[$document];
+ $prefix = preg_quote(CustomFields::keyPrefix(), '/');
+
+ preg_match_all('/filter\['.$prefix.'[\w-]+:(\w+)\]/', $text, $filters);
+ preg_match_all("/function queryOperations\(\): array\s*\{\s*return \[(.*?)\];/s", $text, $declared);
+
+ $operations = $filters[1];
+
+ foreach ($declared[1] as $list) {
+ preg_match_all("/'(\w+)'/", $list, $names);
+ $operations = [...$operations, ...$names[1]];
+ }
+
+ expect($operations)->not->toBeEmpty();
+
+ foreach (array_unique($operations) as $operation) {
+ expect(boostQueryOperations())->toContain($operation);
+ }
+
+ expect($text)->toContain($prefix === '' ? 'filter[' : 'filter['.CustomFields::keyPrefix());
+ })->with(['guideline', 'skill']);
+
+ it('lists the whole operation vocabulary in the skill', function (): void {
+ foreach (boostQueryOperations() as $operation) {
+ expect(boostSkill())->toContain("`{$operation}`");
+ }
+ });
+
+ it('runs only artisan commands that are registered', function (): void {
+ preg_match_all('/php artisan ([\w:-]+)/', implode("\n", boostDocuments()), $commands);
+
+ expect($commands[1])->not->toBeEmpty();
+
+ foreach (array_unique($commands[1]) as $command) {
+ expect(Artisan::all())->toHaveKey($command);
+ }
+ });
+
+ it('names only publish tags the service provider registers', function (): void {
+ $text = implode("\n", boostDocuments());
+
+ preg_match_all('/--tag="([\w-]+)"/', $text, $flags);
+ preg_match_all('/`(laravel-custom-fields(?:-[a-z]+)?)`/', $text, $inline);
+
+ $tags = array_unique([...$flags[1], ...$inline[1]]);
+
+ expect($tags)->not->toBeEmpty();
+
+ foreach ($tags as $tag) {
+ expect(ServiceProvider::publishableGroups())->toContain($tag);
+ }
+ });
+
+ it('names only migrations the package ships', function (): void {
+ preg_match_all('/`(create_\w+_table)`/', implode("\n", boostDocuments()), $migrations);
+
+ expect($migrations[1])->not->toBeEmpty();
+
+ foreach ($migrations[1] as $migration) {
+ expect(glob(__DIR__.'/../../database/migrations/*_'.$migration.'.php'))->not->toBeEmpty();
+ }
+ });
+});