diff --git a/README.md b/README.md index ec2de90..1cd8094 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ - [Adding Sections](#adding-sections) - [Defining Fields](#defining-fields) - [Array & Object Fields](#array-and-object-fields) +- [Ephemeral Templates](#ephemeral-templates) + - [Creating Ephemeral Templates](#creating-ephemeral-templates) + - [Ephemeral Schema & Rendering](#ephemeral-schema-and-rendering) - [JSON Schema Generation](#json-schema-generation) - [Template Schemas](#template-schemas) - [Section Schemas](#section-schemas) @@ -32,7 +35,7 @@ ## Introduction -Schematic is a database-driven templating engine for Laravel that generates JSON Schema definitions from your templates. It is designed for use with LLM structured output APIs such as those provided by OpenAI and Anthropic, allowing you to define templates with typed fields and automatically produce valid JSON Schema for tool use and structured responses. +Schematic is a templating engine for Laravel that generates JSON Schema definitions from your templates. It is designed for use with LLM structured output APIs such as those provided by OpenAI and Anthropic, allowing you to define templates with typed fields and automatically produce valid JSON Schema for tool use and structured responses. Templates can be persisted to the database or created as [ephemeral (in-memory) templates](#ephemeral-templates) for on-the-fly use without any database overhead. ## Installation @@ -195,6 +198,82 @@ TPL, ); ``` + +## Ephemeral Templates + +Ephemeral templates are in-memory templates that are **not persisted to the database**. They are useful for one-off or dynamic templates that you build at runtime — no migrations or database queries required. + +Ephemeral templates support the same core features as database-backed templates: sections, fields, JSON Schema generation, rendering, and previewing. + + +### Creating Ephemeral Templates + +Use the `ephemeral` method on the `Schematic` facade to create an in-memory template: + +```php +use Yannelli\Schematic\Facades\Schematic; + +$template = Schematic::ephemeral( + slug: 'intake-form', + name: 'Patient Intake Form', + description: 'A quick intake form built on the fly', +); + +$template->addSection( + slug: 'demographics', + name: 'Demographics', + content: '{{ patient_name }}, Age: {{ age }}', + fields: [ + ['name' => 'patient_name', 'type' => 'string', 'description' => 'Full name'], + ['name' => 'age', 'type' => 'integer', 'description' => 'Patient age'], + ], + examples: ['patient_name' => 'Jane Doe', 'age' => 34], +); +``` + +You may also create ephemeral templates directly via the `EphemeralTemplate` class: + +```php +use Yannelli\Schematic\Ephemeral\EphemeralTemplate; + +$template = EphemeralTemplate::make('quick-note', 'Quick Note'); +$section = $template->addSection('body', 'Body', content: '{{ note }}'); +$section->addField('note', 'string', 'The note content'); +``` + +Sections on ephemeral templates support the same fluent methods as database-backed sections, including `addField`, `removeField`, `enable`, `disable`, and `setExamples`. All mutations happen in memory. + + +### Ephemeral Schema & Rendering + +Ephemeral templates generate JSON Schema and render content exactly like their database-backed counterparts: + +```php +// JSON Schema generation +$schema = $template->toJsonSchema(); +$doc = $template->toJsonSchemaDocument(); +$sectionSchema = $template->sectionSchema('demographics'); + +// Rendering with data +$output = $template->render([ + 'demographics' => ['patient_name' => 'Alice Smith', 'age' => 28], +]); + +// Preview using example data +$preview = $template->preview(); +``` + +Section management works identically — you can iterate, reorder, enable, and disable sections: + +```php +$template->section('demographics')->disable(); +$template->reorderSections(['body', 'demographics']); + +foreach ($template->iterateSections() as $section) { + // Only enabled sections +} +``` + ## JSON Schema Generation diff --git a/src/Ephemeral/EphemeralSection.php b/src/Ephemeral/EphemeralSection.php new file mode 100644 index 0000000..5c95b19 --- /dev/null +++ b/src/Ephemeral/EphemeralSection.php @@ -0,0 +1,161 @@ + + */ + public function fieldDefinitions(): \Illuminate\Support\Collection + { + return collect($this->fields)->map( + fn (array $field) => FieldDefinition::fromArray($field) + ); + } + + public function toJsonSchema(): array + { + $properties = []; + $required = []; + + foreach ($this->fieldDefinitions() as $field) { + $properties[$field->name] = $field->toJsonSchemaProperty(); + + if ($field->required) { + $required[] = $field->name; + } + } + + $schema = [ + 'type' => 'object', + 'properties' => $properties, + ]; + + if ($required !== []) { + $schema['required'] = $required; + } + + if ($this->description) { + $schema['description'] = $this->description; + } + + if (config('schematic.schema.strict', true)) { + $schema['additionalProperties'] = false; + } + + return $schema; + } + + // --------------------------------------------------------------- + // Rendering + // --------------------------------------------------------------- + + public function render(array $data = []): string + { + if (! $this->is_enabled) { + return ''; + } + + return app(Compiler::class)->compile($this->content ?? '', $data); + } + + public function preview(): string + { + return $this->render($this->examples); + } + + // --------------------------------------------------------------- + // Fluent Builders + // --------------------------------------------------------------- + + public function enable(): static + { + $this->is_enabled = true; + + return $this; + } + + public function disable(): static + { + $this->is_enabled = false; + + return $this; + } + + public function addField( + string $name, + string $type = 'string', + string $description = '', + bool $required = true, + bool $nullable = false, + mixed $default = null, + ?array $enum = null, + ): static { + $this->fields[] = (new FieldDefinition( + name: $name, + type: $type, + description: $description, + required: $required, + nullable: $nullable, + default: $default, + enum: $enum, + ))->jsonSerialize(); + + return $this; + } + + public function removeField(string $name): static + { + $this->fields = collect($this->fields)->reject( + fn (array $f) => $f['name'] === $name + )->values()->all(); + + return $this; + } + + public function setExamples(array $examples): static + { + $this->examples = $examples; + + return $this; + } +} diff --git a/src/Ephemeral/EphemeralTemplate.php b/src/Ephemeral/EphemeralTemplate.php new file mode 100644 index 0000000..04aef96 --- /dev/null +++ b/src/Ephemeral/EphemeralTemplate.php @@ -0,0 +1,222 @@ +nextOrder(); + + $section = new EphemeralSection( + slug: $slug, + name: $name, + description: $description, + content: $content, + fields: $fields, + examples: $examples, + order: $order, + is_enabled: $enabled, + ); + + $this->sections[] = $section; + + return $section; + } + + /** + * Find a section by slug. + */ + public function section(string $slug): ?EphemeralSection + { + foreach ($this->sections as $section) { + if ($section->slug === $slug) { + return $section; + } + } + + return null; + } + + /** + * Get enabled sections sorted by order. + */ + public function iterateSections(): Collection + { + return collect($this->sections) + ->filter(fn (EphemeralSection $s) => $s->is_enabled) + ->sortBy('order') + ->values(); + } + + /** + * Get all sections (including disabled) sorted by order. + */ + public function iterateAllSections(): Collection + { + return collect($this->sections) + ->sortBy('order') + ->values(); + } + + /** + * Reorder sections by slug array. + */ + public function reorderSections(array $slugs): static + { + foreach ($slugs as $index => $slug) { + $section = $this->section($slug); + + if ($section) { + $section->order = $index; + } + } + + return $this; + } + + // --------------------------------------------------------------- + // Schema Generation + // --------------------------------------------------------------- + + public function toJsonSchema(): array + { + $properties = []; + $required = []; + + foreach ($this->iterateSections() as $section) { + $sectionSchema = $section->toJsonSchema(); + $properties[$section->slug] = $sectionSchema; + $required[] = $section->slug; + } + + $schema = [ + 'type' => 'object', + 'properties' => $properties, + ]; + + if ($required !== []) { + $schema['required'] = $required; + } + + if ($this->description) { + $schema['description'] = $this->description; + } + + if (config('schematic.schema.strict', true)) { + $schema['additionalProperties'] = false; + } + + return $schema; + } + + public function toJsonSchemaDocument(): array + { + return [ + '$schema' => config('schematic.schema.draft'), + 'title' => $this->name, + ...$this->toJsonSchema(), + ]; + } + + /** + * Generate JSON Schema for a single section by slug. + */ + public function sectionSchema(string $slug): ?array + { + return $this->section($slug)?->toJsonSchema(); + } + + // --------------------------------------------------------------- + // Rendering + // --------------------------------------------------------------- + + public function render(array $data = []): string + { + $compiler = app(Compiler::class); + $output = []; + + foreach ($this->iterateSections() as $section) { + $sectionData = $data[$section->slug] ?? $data; + $rendered = $section->render($sectionData); + + if ($rendered !== '') { + $output[] = $rendered; + } + } + + return implode("\n\n", $output); + } + + public function preview(): string + { + $output = []; + + foreach ($this->iterateSections() as $section) { + $rendered = $section->preview(); + + if ($rendered !== '') { + $output[] = $rendered; + } + } + + return implode("\n\n", $output); + } + + // --------------------------------------------------------------- + // Internal + // --------------------------------------------------------------- + + protected function nextOrder(): int + { + if (empty($this->sections)) { + return 0; + } + + return max(array_map(fn (EphemeralSection $s) => $s->order, $this->sections)) + 1; + } +} diff --git a/src/Facades/Schematic.php b/src/Facades/Schematic.php index 149823e..e4e9085 100644 --- a/src/Facades/Schematic.php +++ b/src/Facades/Schematic.php @@ -8,6 +8,7 @@ * @method static \Yannelli\Schematic\Models\Template create(string $slug, string $name, ?string $description = null, array $metadata = []) * @method static \Yannelli\Schematic\Models\Template|null find(string $slug) * @method static \Yannelli\Schematic\Models\Template findOrFail(string $slug) + * @method static \Yannelli\Schematic\Ephemeral\EphemeralTemplate ephemeral(string $slug, string $name, ?string $description = null, array $metadata = []) * @method static static macro(string $name, \Closure $handler) * @method static bool hasMacro(string $name) * @method static array schema(string $slug) diff --git a/src/Schematic.php b/src/Schematic.php index 665abb4..ad4077a 100644 --- a/src/Schematic.php +++ b/src/Schematic.php @@ -3,6 +3,7 @@ namespace Yannelli\Schematic; use Closure; +use Yannelli\Schematic\Ephemeral\EphemeralTemplate; use Yannelli\Schematic\Models\Section; use Yannelli\Schematic\Models\Template; @@ -51,6 +52,18 @@ public function findOrFail(string $slug): Template return $modelClass::findBySlugOrFail($slug); } + // --------------------------------------------------------------- + // Ephemeral Templates + // --------------------------------------------------------------- + + /** + * Create an ephemeral (in-memory, non-persisted) template. + */ + public function ephemeral(string $slug, string $name, ?string $description = null, array $metadata = []): EphemeralTemplate + { + return EphemeralTemplate::make($slug, $name, $description, $metadata); + } + // --------------------------------------------------------------- // Macro Registration (proxy to Compiler) // --------------------------------------------------------------- diff --git a/tests/Feature/EphemeralSectionTest.php b/tests/Feature/EphemeralSectionTest.php new file mode 100644 index 0000000..a920d4f --- /dev/null +++ b/tests/Feature/EphemeralSectionTest.php @@ -0,0 +1,220 @@ +slug)->toBe('intro') + ->and($section->name)->toBe('Introduction') + ->and($section->is_enabled)->toBeTrue() + ->and($section->fields)->toBe([]) + ->and($section->examples)->toBe([]); +}); + +it('creates an ephemeral section via make', function () { + $section = EphemeralSection::make( + slug: 'body', + name: 'Body', + description: 'Main content', + content: 'Hello {{ name }}', + fields: [['name' => 'name', 'type' => 'string']], + examples: ['name' => 'World'], + ); + + expect($section->slug)->toBe('body') + ->and($section->description)->toBe('Main content') + ->and($section->content)->toBe('Hello {{ name }}'); +}); + +// --------------------------------------------------------------- +// Schema Generation +// --------------------------------------------------------------- + +it('generates json schema from fields', function () { + $section = EphemeralSection::make( + slug: 'data', + name: 'Data', + description: 'Test section', + fields: [ + ['name' => 'title', 'type' => 'string', 'description' => 'The title', 'required' => true], + ['name' => 'count', 'type' => 'integer', 'description' => 'A count', 'required' => false], + ], + ); + + $schema = $section->toJsonSchema(); + + expect($schema['type'])->toBe('object') + ->and($schema['properties'])->toHaveKey('title') + ->and($schema['properties'])->toHaveKey('count') + ->and($schema['properties']['title']['type'])->toBe('string') + ->and($schema['properties']['count']['type'])->toBe('integer') + ->and($schema['required'])->toBe(['title']) + ->and($schema['description'])->toBe('Test section'); +}); + +it('generates schema with enum fields', function () { + $section = EphemeralSection::make( + slug: 'status', + name: 'Status', + fields: [ + ['name' => 'level', 'type' => 'enum', 'enum' => ['low', 'medium', 'high']], + ], + ); + + $schema = $section->toJsonSchema(); + + expect($schema['properties']['level']['enum'])->toBe(['low', 'medium', 'high']); +}); + +it('generates schema with nullable fields', function () { + $section = EphemeralSection::make( + slug: 'optional', + name: 'Optional', + fields: [ + ['name' => 'notes', 'type' => 'string', 'nullable' => true, 'required' => false], + ], + ); + + $schema = $section->toJsonSchema(); + + expect($schema['properties']['notes']['type'])->toBe(['string', 'null']) + ->and($schema)->not->toHaveKey('required'); +}); + +// --------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------- + +it('renders content with data', function () { + $section = EphemeralSection::make( + slug: 'greeting', + name: 'Greeting', + content: 'Hello, {{ name }}!', + ); + + expect($section->render(['name' => 'Alice']))->toBe('Hello, Alice!'); +}); + +it('renders empty string when disabled', function () { + $section = EphemeralSection::make( + slug: 'disabled', + name: 'Disabled', + content: 'Should not render', + enabled: false, + ); + + expect($section->render())->toBe(''); +}); + +it('previews using example data', function () { + $section = EphemeralSection::make( + slug: 'preview', + name: 'Preview', + content: 'Value: {{ val }}', + examples: ['val' => '42'], + ); + + expect($section->preview())->toBe('Value: 42'); +}); + +it('renders with conditionals', function () { + $section = EphemeralSection::make( + slug: 'cond', + name: 'Conditional', + content: '@if(show)Visible@endif', + ); + + expect($section->render(['show' => true]))->toBe('Visible') + ->and($section->render(['show' => false]))->toBe(''); +}); + +it('renders with loops', function () { + $section = EphemeralSection::make( + slug: 'loop', + name: 'Loop', + content: '@foreach(items as item)- {{ item }}@endforeach', + ); + + $result = $section->render(['items' => ['a', 'b', 'c']]); + + expect($result)->toContain('- a') + ->and($result)->toContain('- b') + ->and($result)->toContain('- c'); +}); + +// --------------------------------------------------------------- +// Fluent Builders +// --------------------------------------------------------------- + +it('adds a field fluently', function () { + $section = EphemeralSection::make(slug: 'test', name: 'Test'); + + $result = $section->addField('email', 'string', 'Email address'); + + expect($result)->toBe($section) + ->and($section->fields)->toHaveCount(1) + ->and($section->fields[0]['name'])->toBe('email') + ->and($section->fields[0]['type'])->toBe('string'); +}); + +it('removes a field fluently', function () { + $section = EphemeralSection::make( + slug: 'test', + name: 'Test', + fields: [ + ['name' => 'keep', 'type' => 'string'], + ['name' => 'remove', 'type' => 'string'], + ], + ); + + $result = $section->removeField('remove'); + + expect($result)->toBe($section) + ->and($section->fields)->toHaveCount(1) + ->and($section->fields[0]['name'])->toBe('keep'); +}); + +it('enables and disables fluently', function () { + $section = EphemeralSection::make(slug: 'toggle', name: 'Toggle'); + + expect($section->disable())->toBe($section) + ->and($section->is_enabled)->toBeFalse(); + + expect($section->enable())->toBe($section) + ->and($section->is_enabled)->toBeTrue(); +}); + +it('sets examples fluently', function () { + $section = EphemeralSection::make(slug: 'ex', name: 'Ex'); + + $result = $section->setExamples(['key' => 'value']); + + expect($result)->toBe($section) + ->and($section->examples)->toBe(['key' => 'value']); +}); + +it('returns field definitions as FieldDefinition objects', function () { + $section = EphemeralSection::make( + slug: 'defs', + name: 'Defs', + fields: [ + ['name' => 'title', 'type' => 'string'], + ['name' => 'count', 'type' => 'integer'], + ], + ); + + $definitions = $section->fieldDefinitions(); + + expect($definitions)->toHaveCount(2) + ->and($definitions[0])->toBeInstanceOf(\Yannelli\Schematic\FieldDefinition::class) + ->and($definitions[0]->name)->toBe('title') + ->and($definitions[1]->type)->toBe('integer'); +}); diff --git a/tests/Feature/EphemeralTemplateTest.php b/tests/Feature/EphemeralTemplateTest.php new file mode 100644 index 0000000..58067ba --- /dev/null +++ b/tests/Feature/EphemeralTemplateTest.php @@ -0,0 +1,266 @@ +slug)->toBe('quick-note') + ->and($template->name)->toBe('Quick Note') + ->and($template->description)->toBe('A quick note template'); +}); + +it('creates an ephemeral template via make', function () { + $template = EphemeralTemplate::make( + slug: 'test', + name: 'Test', + metadata: ['version' => '1.0'], + ); + + expect($template->slug)->toBe('test') + ->and($template->metadata)->toBe(['version' => '1.0']); +}); + +it('creates an ephemeral template via schematic service', function () { + $schematic = app(Schematic::class); + + $template = $schematic->ephemeral('service-ephemeral', 'Service Ephemeral'); + + expect($template)->toBeInstanceOf(EphemeralTemplate::class) + ->and($template->slug)->toBe('service-ephemeral'); +}); + +// --------------------------------------------------------------- +// Section Management +// --------------------------------------------------------------- + +it('adds sections and returns the section', function () { + $template = EphemeralTemplate::make('t', 'T'); + + $section = $template->addSection( + slug: 'intro', + name: 'Introduction', + content: 'Hello {{ name }}', + fields: [['name' => 'name', 'type' => 'string']], + ); + + expect($section)->toBeInstanceOf(EphemeralSection::class) + ->and($section->slug)->toBe('intro'); +}); + +it('finds a section by slug', function () { + $template = EphemeralTemplate::make('t', 'T'); + $template->addSection(slug: 'a', name: 'A'); + $template->addSection(slug: 'b', name: 'B'); + + expect($template->section('b'))->not->toBeNull() + ->and($template->section('b')->name)->toBe('B') + ->and($template->section('missing'))->toBeNull(); +}); + +it('auto-increments section order', function () { + $template = EphemeralTemplate::make('t', 'T'); + $s1 = $template->addSection(slug: 'a', name: 'A'); + $s2 = $template->addSection(slug: 'b', name: 'B'); + $s3 = $template->addSection(slug: 'c', name: 'C'); + + expect($s1->order)->toBe(0) + ->and($s2->order)->toBe(1) + ->and($s3->order)->toBe(2); +}); + +it('iterates only enabled sections', function () { + $template = EphemeralTemplate::make('t', 'T'); + $template->addSection(slug: 'a', name: 'A'); + $template->addSection(slug: 'b', name: 'B', enabled: false); + $template->addSection(slug: 'c', name: 'C'); + + $slugs = $template->iterateSections()->pluck('slug')->all(); + + expect($slugs)->toBe(['a', 'c']); +}); + +it('iterates all sections including disabled', function () { + $template = EphemeralTemplate::make('t', 'T'); + $template->addSection(slug: 'a', name: 'A'); + $template->addSection(slug: 'b', name: 'B', enabled: false); + + expect($template->iterateAllSections())->toHaveCount(2); +}); + +it('reorders sections', function () { + $template = EphemeralTemplate::make('t', 'T'); + $template->addSection(slug: 'a', name: 'A'); + $template->addSection(slug: 'b', name: 'B'); + $template->addSection(slug: 'c', name: 'C'); + + $template->reorderSections(['c', 'a', 'b']); + + $slugs = $template->iterateSections()->pluck('slug')->all(); + + expect($slugs)->toBe(['c', 'a', 'b']); +}); + +// --------------------------------------------------------------- +// Schema Generation +// --------------------------------------------------------------- + +it('generates json schema for all enabled sections', function () { + $template = EphemeralTemplate::make('t', 'T', 'Description'); + $template->addSection(slug: 'info', name: 'Info', fields: [ + ['name' => 'title', 'type' => 'string'], + ]); + $template->addSection(slug: 'hidden', name: 'Hidden', enabled: false, fields: [ + ['name' => 'secret', 'type' => 'string'], + ]); + + $schema = $template->toJsonSchema(); + + expect($schema['type'])->toBe('object') + ->and($schema['properties'])->toHaveKey('info') + ->and($schema['properties'])->not->toHaveKey('hidden') + ->and($schema['required'])->toBe(['info']) + ->and($schema['description'])->toBe('Description'); +}); + +it('generates full schema document', function () { + $template = EphemeralTemplate::make('doc', 'Document'); + $template->addSection(slug: 's', name: 'S', fields: [ + ['name' => 'f', 'type' => 'string'], + ]); + + $doc = $template->toJsonSchemaDocument(); + + expect($doc)->toHaveKey('$schema') + ->and($doc['title'])->toBe('Document') + ->and($doc['type'])->toBe('object'); +}); + +it('generates section schema by slug', function () { + $template = EphemeralTemplate::make('t', 'T'); + $template->addSection(slug: 'part', name: 'Part', fields: [ + ['name' => 'key', 'type' => 'string'], + ]); + + $schema = $template->sectionSchema('part'); + + expect($schema['properties'])->toHaveKey('key'); +}); + +it('returns null for missing section schema', function () { + $template = EphemeralTemplate::make('t', 'T'); + + expect($template->sectionSchema('missing'))->toBeNull(); +}); + +// --------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------- + +it('renders all enabled sections with data', function () { + $template = EphemeralTemplate::make('t', 'T'); + $template->addSection( + slug: 'greeting', + name: 'Greeting', + content: 'Hello, {{ name }}!', + ); + $template->addSection( + slug: 'footer', + name: 'Footer', + content: 'Goodbye, {{ name }}!', + ); + + $result = $template->render([ + 'greeting' => ['name' => 'Alice'], + 'footer' => ['name' => 'Bob'], + ]); + + expect($result)->toBe("Hello, Alice!\n\nGoodbye, Bob!"); +}); + +it('skips disabled sections when rendering', function () { + $template = EphemeralTemplate::make('t', 'T'); + $template->addSection(slug: 'visible', name: 'Visible', content: 'Yes'); + $template->addSection(slug: 'hidden', name: 'Hidden', content: 'No', enabled: false); + + $result = $template->render(); + + expect($result)->toBe('Yes'); +}); + +it('falls back to flat data when section key missing', function () { + $template = EphemeralTemplate::make('t', 'T'); + $template->addSection(slug: 'body', name: 'Body', content: 'Val: {{ val }}'); + + $result = $template->render(['val' => '42']); + + expect($result)->toBe('Val: 42'); +}); + +it('previews using example data', function () { + $template = EphemeralTemplate::make('t', 'T'); + $template->addSection( + slug: 'demo', + name: 'Demo', + content: 'Example: {{ value }}', + examples: ['value' => 'test'], + ); + + expect($template->preview())->toBe('Example: test'); +}); + +// --------------------------------------------------------------- +// Disabled sections after creation +// --------------------------------------------------------------- + +it('supports enable and disable on sections after creation', function () { + $template = EphemeralTemplate::make('t', 'T'); + $section = $template->addSection(slug: 'toggle', name: 'Toggle', content: 'Content'); + + expect($template->render())->toBe('Content'); + + $section->disable(); + + expect($template->render())->toBe(''); + + $section->enable(); + + expect($template->render())->toBe('Content'); +}); + +// --------------------------------------------------------------- +// No Database Queries +// --------------------------------------------------------------- + +it('executes no database queries', function () { + \Illuminate\Support\Facades\DB::enableQueryLog(); + + $template = EphemeralTemplate::make('no-db', 'No DB', 'Test'); + $template->addSection( + slug: 'body', + name: 'Body', + content: 'Hello {{ name }}', + fields: [['name' => 'name', 'type' => 'string']], + examples: ['name' => 'World'], + ); + + $template->toJsonSchema(); + $template->toJsonSchemaDocument(); + $template->sectionSchema('body'); + $template->render(['body' => ['name' => 'Test']]); + $template->preview(); + + $queries = \Illuminate\Support\Facades\DB::getQueryLog(); + + expect($queries)->toBeEmpty(); +});