From 4e43a7eab230228de837ee5d6caf7b63e125b235 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Wed, 11 Mar 2026 16:18:06 +0200 Subject: [PATCH 01/21] Laravel/boost upgrade to v2. Remove WARP.md --- WARP.md | 72 -- _api_app/.agents/skills/pest-testing/SKILL.md | 117 ++ _api_app/.claude/skills/pest-testing/SKILL.md | 117 ++ _api_app/.codex/config.toml | 4 + _api_app/.cursor/skills/pest-testing/SKILL.md | 117 ++ _api_app/.github/skills/pest-testing/SKILL.md | 117 ++ _api_app/AGENTS.md | 222 ++-- _api_app/CLAUDE.md | 222 ++-- _api_app/boost.json | 17 + _api_app/composer.json | 2 +- _api_app/composer.lock | 1068 +++++++++-------- _api_app/opencode.json | 14 + 12 files changed, 1269 insertions(+), 820 deletions(-) delete mode 100644 WARP.md create mode 100644 _api_app/.agents/skills/pest-testing/SKILL.md create mode 100644 _api_app/.claude/skills/pest-testing/SKILL.md create mode 100644 _api_app/.codex/config.toml create mode 100644 _api_app/.cursor/skills/pest-testing/SKILL.md create mode 100644 _api_app/.github/skills/pest-testing/SKILL.md create mode 100644 _api_app/boost.json create mode 100644 _api_app/opencode.json diff --git a/WARP.md b/WARP.md deleted file mode 100644 index b4706d810..000000000 --- a/WARP.md +++ /dev/null @@ -1,72 +0,0 @@ -# WARP.md - -This file provides guidance to WARP (warp.dev) when working with code in this repository. - -## Development Commands - -### Root Project (Frontend Assets) -- **Install dependencies**: `npm install` -- **Development build with watch**: `npm run dev` (runs `gulp`) -- **Production build**: `npm run build` (runs `gulp build`) -- **Lint backend JS**: `gulp` includes JSHint for backend JS files - -### Angular Editor (_editor/_ directory) -- **Install dependencies**: `cd editor && npm install` -- **Development server**: `cd editor && npm start` (runs `ng serve` on http://localhost:4200) -- **Build for development**: `cd editor && npm run dev` (builds with watch) -- **Build for production**: `cd editor && npm run build` -- **Run tests**: `cd editor && npm test` - -### Laravel API (_api_app/_ directory) -- **Install dependencies**: `cd _api_app && composer install` -- **Development server**: `cd _api_app && npm run dev` (Vite dev server) -- **Build assets**: `cd _api_app && npm run build` -- **Run tests**: `cd _api_app && ./vendor/bin/pest` -- **Run tests (CI mode)**: `cd _api_app && ./vendor/bin/pest --ci` - -## Project Architecture - -### Multi-Component CMS System -Berta is a file-based CMS consisting of three main components that work together: - -1. **Legacy PHP Engine** (`engine/` directory) - Original Berta CMS core -2. **Angular Editor** (`editor/` directory) - Modern admin interface built with Angular 8 -3. **Laravel API** (`_api_app/` directory) - REST API backend using Laravel 12 - -### Key Architectural Components - -#### Frontend Build System (Gulp) -- Compiles SCSS to CSS for multiple templates -- Concatenates and minifies JS/CSS assets -- Builds separate bundles for frontend and backend -- Template-specific SCSS compilation for themes in `_templates/` - -#### Template System -- Templates located in `_templates/` directory (default, messy, white, mashup) -- Each template has its own SCSS files that are compiled separately -- Template CSS is built to template-specific directories - -#### Angular Editor -- NGXS state management for application state -- Component-based modular architecture with inline templates -- Outputs built files to `../engine/dist/` - -#### Laravel API Backend -- Modern Laravel 12 application -- Pest testing framework -- Vite for asset compilation -- RESTful API structure - -### Entry Points -- **Main site**: `index.php` (delegates to `engine/index.php`) -- **Editor interface**: Angular app served from `engine/dist/` -- **API endpoints**: Laravel routes in `_api_app/routes/` - -### Development Workflow -1. **Frontend assets**: Use `npm run dev` in root for CSS/JS compilation with watch -2. **Admin interface**: Use `npm start` in `editor/` for Angular development server -3. **API development**: Use Laravel's built-in server or Vite dev server in `_api_app/` - -### File Storage -- Content stored in files (not database) as per CMS design -- Storage directory contains user uploads and content files diff --git a/_api_app/.agents/skills/pest-testing/SKILL.md b/_api_app/.agents/skills/pest-testing/SKILL.md new file mode 100644 index 000000000..f6973277f --- /dev/null +++ b/_api_app/.agents/skills/pest-testing/SKILL.md @@ -0,0 +1,117 @@ +--- +name: pest-testing +description: "Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works." +license: MIT +metadata: + author: laravel +--- + +# Pest Testing 3 + +## When to Apply + +Activate this skill when: +- Creating new tests (unit or feature) +- Modifying existing tests +- Debugging test failures +- Working with datasets, mocking, or test organization +- Writing architecture tests + +## Documentation + +Use `search-docs` for detailed Pest 3 patterns and documentation. + +## Basic Usage + +### Creating Tests + +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. + +### Test Organization + +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Do NOT remove tests without approval - these are core application code. +- Test happy paths, failure paths, and edge cases. + +### Basic Test Structure + + +```php +it('is true', function () { + expect(true)->toBeTrue(); +}); +``` + +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. + +## Assertions + +Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + + +```php +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); +``` + +| Use | Instead of | +|-----|------------| +| `assertSuccessful()` | `assertStatus(200)` | +| `assertNotFound()` | `assertStatus(404)` | +| `assertForbidden()` | `assertStatus(403)` | + +## Mocking + +Import mock function before use: `use function Pest\Laravel\mock;` + +## Datasets + +Use datasets for repetitive tests (validation rules, etc.): + + +```php +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +``` + +## Pest 3 Features + +### Architecture Testing + +Pest 3 includes architecture testing to enforce code conventions: + + +```php +arch('controllers') + ->expect('App\Http\Controllers') + ->toExtendNothing() + ->toHaveSuffix('Controller'); + +arch('models') + ->expect('App\Models') + ->toExtend('Illuminate\Database\Eloquent\Model'); + +arch('no debugging') + ->expect(['dd', 'dump', 'ray']) + ->not->toBeUsed(); +``` + +### Type Coverage + +Pest 3 provides improved type coverage analysis. Run with `--type-coverage` flag. + +## Common Pitfalls + +- Not importing `use function Pest\Laravel\mock;` before using mock +- Using `assertStatus(200)` instead of `assertSuccessful()` +- Forgetting datasets for repetitive validation tests +- Deleting tests without approval \ No newline at end of file diff --git a/_api_app/.claude/skills/pest-testing/SKILL.md b/_api_app/.claude/skills/pest-testing/SKILL.md new file mode 100644 index 000000000..f6973277f --- /dev/null +++ b/_api_app/.claude/skills/pest-testing/SKILL.md @@ -0,0 +1,117 @@ +--- +name: pest-testing +description: "Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works." +license: MIT +metadata: + author: laravel +--- + +# Pest Testing 3 + +## When to Apply + +Activate this skill when: +- Creating new tests (unit or feature) +- Modifying existing tests +- Debugging test failures +- Working with datasets, mocking, or test organization +- Writing architecture tests + +## Documentation + +Use `search-docs` for detailed Pest 3 patterns and documentation. + +## Basic Usage + +### Creating Tests + +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. + +### Test Organization + +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Do NOT remove tests without approval - these are core application code. +- Test happy paths, failure paths, and edge cases. + +### Basic Test Structure + + +```php +it('is true', function () { + expect(true)->toBeTrue(); +}); +``` + +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. + +## Assertions + +Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + + +```php +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); +``` + +| Use | Instead of | +|-----|------------| +| `assertSuccessful()` | `assertStatus(200)` | +| `assertNotFound()` | `assertStatus(404)` | +| `assertForbidden()` | `assertStatus(403)` | + +## Mocking + +Import mock function before use: `use function Pest\Laravel\mock;` + +## Datasets + +Use datasets for repetitive tests (validation rules, etc.): + + +```php +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +``` + +## Pest 3 Features + +### Architecture Testing + +Pest 3 includes architecture testing to enforce code conventions: + + +```php +arch('controllers') + ->expect('App\Http\Controllers') + ->toExtendNothing() + ->toHaveSuffix('Controller'); + +arch('models') + ->expect('App\Models') + ->toExtend('Illuminate\Database\Eloquent\Model'); + +arch('no debugging') + ->expect(['dd', 'dump', 'ray']) + ->not->toBeUsed(); +``` + +### Type Coverage + +Pest 3 provides improved type coverage analysis. Run with `--type-coverage` flag. + +## Common Pitfalls + +- Not importing `use function Pest\Laravel\mock;` before using mock +- Using `assertStatus(200)` instead of `assertSuccessful()` +- Forgetting datasets for repetitive validation tests +- Deleting tests without approval \ No newline at end of file diff --git a/_api_app/.codex/config.toml b/_api_app/.codex/config.toml new file mode 100644 index 000000000..722d917ec --- /dev/null +++ b/_api_app/.codex/config.toml @@ -0,0 +1,4 @@ +[mcp_servers.laravel-boost] +command = "php" +args = ["artisan", "boost:mcp"] +cwd = "/Users/uldis/projects/berta/berta/_api_app" diff --git a/_api_app/.cursor/skills/pest-testing/SKILL.md b/_api_app/.cursor/skills/pest-testing/SKILL.md new file mode 100644 index 000000000..f6973277f --- /dev/null +++ b/_api_app/.cursor/skills/pest-testing/SKILL.md @@ -0,0 +1,117 @@ +--- +name: pest-testing +description: "Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works." +license: MIT +metadata: + author: laravel +--- + +# Pest Testing 3 + +## When to Apply + +Activate this skill when: +- Creating new tests (unit or feature) +- Modifying existing tests +- Debugging test failures +- Working with datasets, mocking, or test organization +- Writing architecture tests + +## Documentation + +Use `search-docs` for detailed Pest 3 patterns and documentation. + +## Basic Usage + +### Creating Tests + +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. + +### Test Organization + +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Do NOT remove tests without approval - these are core application code. +- Test happy paths, failure paths, and edge cases. + +### Basic Test Structure + + +```php +it('is true', function () { + expect(true)->toBeTrue(); +}); +``` + +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. + +## Assertions + +Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + + +```php +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); +``` + +| Use | Instead of | +|-----|------------| +| `assertSuccessful()` | `assertStatus(200)` | +| `assertNotFound()` | `assertStatus(404)` | +| `assertForbidden()` | `assertStatus(403)` | + +## Mocking + +Import mock function before use: `use function Pest\Laravel\mock;` + +## Datasets + +Use datasets for repetitive tests (validation rules, etc.): + + +```php +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +``` + +## Pest 3 Features + +### Architecture Testing + +Pest 3 includes architecture testing to enforce code conventions: + + +```php +arch('controllers') + ->expect('App\Http\Controllers') + ->toExtendNothing() + ->toHaveSuffix('Controller'); + +arch('models') + ->expect('App\Models') + ->toExtend('Illuminate\Database\Eloquent\Model'); + +arch('no debugging') + ->expect(['dd', 'dump', 'ray']) + ->not->toBeUsed(); +``` + +### Type Coverage + +Pest 3 provides improved type coverage analysis. Run with `--type-coverage` flag. + +## Common Pitfalls + +- Not importing `use function Pest\Laravel\mock;` before using mock +- Using `assertStatus(200)` instead of `assertSuccessful()` +- Forgetting datasets for repetitive validation tests +- Deleting tests without approval \ No newline at end of file diff --git a/_api_app/.github/skills/pest-testing/SKILL.md b/_api_app/.github/skills/pest-testing/SKILL.md new file mode 100644 index 000000000..f6973277f --- /dev/null +++ b/_api_app/.github/skills/pest-testing/SKILL.md @@ -0,0 +1,117 @@ +--- +name: pest-testing +description: "Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works." +license: MIT +metadata: + author: laravel +--- + +# Pest Testing 3 + +## When to Apply + +Activate this skill when: +- Creating new tests (unit or feature) +- Modifying existing tests +- Debugging test failures +- Working with datasets, mocking, or test organization +- Writing architecture tests + +## Documentation + +Use `search-docs` for detailed Pest 3 patterns and documentation. + +## Basic Usage + +### Creating Tests + +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. + +### Test Organization + +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Do NOT remove tests without approval - these are core application code. +- Test happy paths, failure paths, and edge cases. + +### Basic Test Structure + + +```php +it('is true', function () { + expect(true)->toBeTrue(); +}); +``` + +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. + +## Assertions + +Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + + +```php +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); +``` + +| Use | Instead of | +|-----|------------| +| `assertSuccessful()` | `assertStatus(200)` | +| `assertNotFound()` | `assertStatus(404)` | +| `assertForbidden()` | `assertStatus(403)` | + +## Mocking + +Import mock function before use: `use function Pest\Laravel\mock;` + +## Datasets + +Use datasets for repetitive tests (validation rules, etc.): + + +```php +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); +``` + +## Pest 3 Features + +### Architecture Testing + +Pest 3 includes architecture testing to enforce code conventions: + + +```php +arch('controllers') + ->expect('App\Http\Controllers') + ->toExtendNothing() + ->toHaveSuffix('Controller'); + +arch('models') + ->expect('App\Models') + ->toExtend('Illuminate\Database\Eloquent\Model'); + +arch('no debugging') + ->expect(['dd', 'dump', 'ray']) + ->not->toBeUsed(); +``` + +### Type Coverage + +Pest 3 provides improved type coverage analysis. Run with `--type-coverage` flag. + +## Common Pitfalls + +- Not importing `use function Pest\Laravel\mock;` before using mock +- Using `assertStatus(200)` instead of `assertSuccessful()` +- Forgetting datasets for repetitive validation tests +- Deleting tests without approval \ No newline at end of file diff --git a/_api_app/AGENTS.md b/_api_app/AGENTS.md index c3ec68551..757ae9646 100644 --- a/_api_app/AGENTS.md +++ b/_api_app/AGENTS.md @@ -3,247 +3,235 @@ # Laravel Boost Guidelines -The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. ## Foundational Context + This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. -- php - 8.2.29 +- php - 8.2.30 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 - laravel/sanctum (SANCTUM) - v4 +- laravel/boost (BOOST) - v2 - laravel/mcp (MCP) - v0 - laravel/pint (PINT) - v1 - laravel/sail (SAIL) - v1 - pestphp/pest (PEST) - v3 - phpunit/phpunit (PHPUNIT) - v11 +## Skills Activation + +This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +- `pest-testing` — Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works. ## Conventions -- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. + +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Check for existing components to reuse before writing a new one. ## Verification Scripts -- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. ## Application Structure & Architecture -- Stick to existing directory structure - don't create new base folders without approval. + +- Stick to existing directory structure; don't create new base folders without approval. - Do not change the application's dependencies without approval. ## Frontend Bundling -- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. -## Replies -- Be concise in your explanations - focus on what's important rather than explaining obvious details. +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. ## Documentation Files + - You must only create documentation files if explicitly requested by the user. +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. === boost rules === -## Laravel Boost +# Laravel Boost + - Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. ## Artisan -- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters. ## URLs -- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port. ## Tinker / Debugging + - You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. - Use the `database-query` tool when you only need to read from the database. +- Use the `database-schema` tool to inspect table structure before writing migrations or models. ## Reading Browser Logs With the `browser-logs` Tool + - You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. - Only recent browser logs will be useful - ignore old logs. ## Searching Documentation (Critically Important) -- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. -- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. -- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. + +- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. - Search the documentation before making code changes to ensure we are taking the correct approach. -- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. -- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. +- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first. +- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. ### Available Search Syntax -- You can and should pass multiple queries at once. The most relevant results will be returned first. - -1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' -2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" -3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order -4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" -5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'. +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit". +3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order. +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit". +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms. === php rules === -## PHP +# PHP + +- Always use curly braces for control structures, even for single-line bodies. -- Always use curly braces for control structures, even if it has one line. +## Constructors -### Constructors - Use PHP 8 constructor property promotion in `__construct()`. - - public function __construct(public GitHub $github) { } -- Do not allow empty `__construct()` methods with zero parameters. + - `public function __construct(public GitHub $github) { }` +- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private. + +## Type Declarations -### Type Declarations - Always use explicit return type declarations for methods and functions. - Use appropriate PHP type hints for method parameters. - + +```php protected function isAccessible(User $user, ?string $path = null): bool { ... } - +``` + +## Enums + +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. ## Comments -- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex. ## PHPDoc Blocks -- Add useful array shape type definitions for arrays when appropriate. -## Enums -- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. +- Add useful array shape type definitions when appropriate. + +=== tests rules === +# Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. === laravel/core rules === -## Do Things the Laravel Way +# Do Things the Laravel Way - Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. -- If you're creating a generic PHP class, use `artisan make:class`. +- If you're creating a generic PHP class, use `php artisan make:class`. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. -### Database +## Database + - Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. -- Use Eloquent models and relationships before suggesting raw database queries +- Use Eloquent models and relationships before suggesting raw database queries. - Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. - Generate code that prevents N+1 query problems by using eager loading. - Use Laravel's query builder for very complex database operations. ### Model Creation + - When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. ### APIs & Eloquent Resources + - For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. -### Controllers & Validation +## Controllers & Validation + - Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. - Check sibling Form Requests to see if the application uses array or string based validation rules. -### Queues -- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. +## Authentication & Authorization -### Authentication & Authorization - Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). -### URL Generation +## URL Generation + - When generating links to other pages, prefer named routes and the `route()` function. -### Configuration +## Queues + +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +## Configuration + - Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. -### Testing +## Testing + - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. -- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. +- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. -### Vite Error -- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. +## Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. === laravel/v12 rules === -## Laravel 12 +# Laravel 12 -- Use the `search-docs` tool to get version specific documentation. +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses. -### Laravel 12 Structure -- No middleware files in `app/Http/Middleware/`. +## Laravel 12 Structure + +- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. +- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/providers.php` contains application specific service providers. -- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. -- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. +- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. + +## Database -### Database - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. -- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. ### Models -- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. === pint/core rules === -## Laravel Pint Code Formatter +# Laravel Pint Code Formatter -- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. - Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. - === pest/core rules === ## Pest -### Testing -- If you need to verify a feature is working, write or update a Unit / Feature test. - -### Pest Tests -- All tests must be written using Pest. Use `php artisan make:test --pest `. -- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. -- Tests should test all of the happy paths, failure paths, and weird paths. -- Tests live in the `tests/Feature` and `tests/Unit` directories. -- Pest tests look and behave like this: - -it('is true', function () { - expect(true)->toBeTrue(); -}); - - -### Running Tests -- Run the minimal number of tests using an appropriate filter before finalizing code edits. -- To run all tests: `php artisan test`. -- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. -- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). -- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. - -### Pest Assertions -- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: - -it('returns all', function () { - $response = $this->postJson('/api/docs', []); - - $response->assertSuccessful(); -}); - - -### Mocking -- Mocking can be very helpful when appropriate. -- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. -- You can also create partial mocks using the same import or self method. - -### Datasets -- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. - - -it('has emails', function (string $email) { - expect($email)->not->toBeEmpty(); -})->with([ - 'james' => 'james@laravel.com', - 'taylor' => 'taylor@laravel.com', -]); - +- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. +- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. +- Do NOT delete tests without approval. +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. +- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. - -=== tests rules === - -## Test Enforcement - -- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. -- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. - \ No newline at end of file + diff --git a/_api_app/CLAUDE.md b/_api_app/CLAUDE.md index c3ec68551..757ae9646 100644 --- a/_api_app/CLAUDE.md +++ b/_api_app/CLAUDE.md @@ -3,247 +3,235 @@ # Laravel Boost Guidelines -The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. ## Foundational Context + This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. -- php - 8.2.29 +- php - 8.2.30 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 - laravel/sanctum (SANCTUM) - v4 +- laravel/boost (BOOST) - v2 - laravel/mcp (MCP) - v0 - laravel/pint (PINT) - v1 - laravel/sail (SAIL) - v1 - pestphp/pest (PEST) - v3 - phpunit/phpunit (PHPUNIT) - v11 +## Skills Activation + +This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +- `pest-testing` — Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works. ## Conventions -- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. + +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Check for existing components to reuse before writing a new one. ## Verification Scripts -- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. ## Application Structure & Architecture -- Stick to existing directory structure - don't create new base folders without approval. + +- Stick to existing directory structure; don't create new base folders without approval. - Do not change the application's dependencies without approval. ## Frontend Bundling -- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. -## Replies -- Be concise in your explanations - focus on what's important rather than explaining obvious details. +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. ## Documentation Files + - You must only create documentation files if explicitly requested by the user. +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. === boost rules === -## Laravel Boost +# Laravel Boost + - Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. ## Artisan -- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters. ## URLs -- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port. ## Tinker / Debugging + - You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. - Use the `database-query` tool when you only need to read from the database. +- Use the `database-schema` tool to inspect table structure before writing migrations or models. ## Reading Browser Logs With the `browser-logs` Tool + - You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. - Only recent browser logs will be useful - ignore old logs. ## Searching Documentation (Critically Important) -- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. -- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. -- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. + +- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. - Search the documentation before making code changes to ensure we are taking the correct approach. -- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. -- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. +- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first. +- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. ### Available Search Syntax -- You can and should pass multiple queries at once. The most relevant results will be returned first. - -1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' -2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" -3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order -4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" -5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'. +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit". +3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order. +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit". +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms. === php rules === -## PHP +# PHP + +- Always use curly braces for control structures, even for single-line bodies. -- Always use curly braces for control structures, even if it has one line. +## Constructors -### Constructors - Use PHP 8 constructor property promotion in `__construct()`. - - public function __construct(public GitHub $github) { } -- Do not allow empty `__construct()` methods with zero parameters. + - `public function __construct(public GitHub $github) { }` +- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private. + +## Type Declarations -### Type Declarations - Always use explicit return type declarations for methods and functions. - Use appropriate PHP type hints for method parameters. - + +```php protected function isAccessible(User $user, ?string $path = null): bool { ... } - +``` + +## Enums + +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. ## Comments -- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex. ## PHPDoc Blocks -- Add useful array shape type definitions for arrays when appropriate. -## Enums -- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. +- Add useful array shape type definitions when appropriate. + +=== tests rules === +# Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. === laravel/core rules === -## Do Things the Laravel Way +# Do Things the Laravel Way - Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. -- If you're creating a generic PHP class, use `artisan make:class`. +- If you're creating a generic PHP class, use `php artisan make:class`. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. -### Database +## Database + - Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. -- Use Eloquent models and relationships before suggesting raw database queries +- Use Eloquent models and relationships before suggesting raw database queries. - Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. - Generate code that prevents N+1 query problems by using eager loading. - Use Laravel's query builder for very complex database operations. ### Model Creation + - When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. ### APIs & Eloquent Resources + - For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. -### Controllers & Validation +## Controllers & Validation + - Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. - Check sibling Form Requests to see if the application uses array or string based validation rules. -### Queues -- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. +## Authentication & Authorization -### Authentication & Authorization - Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). -### URL Generation +## URL Generation + - When generating links to other pages, prefer named routes and the `route()` function. -### Configuration +## Queues + +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +## Configuration + - Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. -### Testing +## Testing + - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. - Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. -- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. +- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. -### Vite Error -- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. +## Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. === laravel/v12 rules === -## Laravel 12 +# Laravel 12 -- Use the `search-docs` tool to get version specific documentation. +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses. -### Laravel 12 Structure -- No middleware files in `app/Http/Middleware/`. +## Laravel 12 Structure + +- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. +- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/providers.php` contains application specific service providers. -- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. -- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. +- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. + +## Database -### Database - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. -- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. ### Models -- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. === pint/core rules === -## Laravel Pint Code Formatter +# Laravel Pint Code Formatter -- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. - Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. - === pest/core rules === ## Pest -### Testing -- If you need to verify a feature is working, write or update a Unit / Feature test. - -### Pest Tests -- All tests must be written using Pest. Use `php artisan make:test --pest `. -- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. -- Tests should test all of the happy paths, failure paths, and weird paths. -- Tests live in the `tests/Feature` and `tests/Unit` directories. -- Pest tests look and behave like this: - -it('is true', function () { - expect(true)->toBeTrue(); -}); - - -### Running Tests -- Run the minimal number of tests using an appropriate filter before finalizing code edits. -- To run all tests: `php artisan test`. -- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. -- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). -- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. - -### Pest Assertions -- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: - -it('returns all', function () { - $response = $this->postJson('/api/docs', []); - - $response->assertSuccessful(); -}); - - -### Mocking -- Mocking can be very helpful when appropriate. -- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. -- You can also create partial mocks using the same import or self method. - -### Datasets -- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. - - -it('has emails', function (string $email) { - expect($email)->not->toBeEmpty(); -})->with([ - 'james' => 'james@laravel.com', - 'taylor' => 'taylor@laravel.com', -]); - +- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. +- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. +- Do NOT delete tests without approval. +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. +- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. - -=== tests rules === - -## Test Enforcement - -- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. -- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. - \ No newline at end of file + diff --git a/_api_app/boost.json b/_api_app/boost.json new file mode 100644 index 000000000..1f17a8721 --- /dev/null +++ b/_api_app/boost.json @@ -0,0 +1,17 @@ +{ + "agents": [ + "cursor", + "claude_code", + "codex", + "copilot", + "opencode" + ], + "guidelines": true, + "herd_mcp": false, + "mcp": true, + "nightwatch_mcp": false, + "sail": false, + "skills": [ + "pest-testing" + ] +} diff --git a/_api_app/composer.json b/_api_app/composer.json index a21a8a346..2040f76d2 100644 --- a/_api_app/composer.json +++ b/_api_app/composer.json @@ -24,7 +24,7 @@ }, "require-dev": { "fakerphp/faker": "^1.23", - "laravel/boost": "^1.1", + "laravel/boost": "^2.0", "laravel/pint": "^1.21", "laravel/sail": "^1.41", "mockery/mockery": "^1.6", diff --git a/_api_app/composer.lock b/_api_app/composer.lock index 1cff27801..6f8a592ee 100644 --- a/_api_app/composer.lock +++ b/_api_app/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4c8e8fb68f05f9a8aed6bd1763eb523e", + "content-hash": "0db6c1107900b00f25f6a4e398f471c2", "packages": [ { "name": "brick/math", - "version": "0.14.0", + "version": "0.14.8", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2" + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", - "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { @@ -56,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.0" + "source": "https://github.com/brick/math/tree/0.14.8" }, "funding": [ { @@ -64,7 +64,7 @@ "type": "github" } ], - "time": "2025-08-29T12:40:03+00:00" + "time": "2026-02-10T14:33:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -379,29 +379,28 @@ }, { "name": "dragonmantank/cron-expression", - "version": "v3.4.0", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "8c784d071debd117328803d86b2097615b457500" + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500", - "reference": "8c784d071debd117328803d86b2097615b457500", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", "shasum": "" }, "require": { - "php": "^7.2|^8.0", - "webmozart/assert": "^1.0" + "php": "^8.2|^8.3|^8.4|^8.5" }, "replace": { "mtdowling/cron-expression": "^1.0" }, "require-dev": { - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.0", - "phpunit/phpunit": "^7.0|^8.0|^9.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" }, "type": "library", "extra": { @@ -432,7 +431,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" }, "funding": [ { @@ -440,7 +439,7 @@ "type": "github" } ], - "time": "2024-10-09T13:47:03+00:00" + "time": "2025-10-31T18:51:33+00:00" }, { "name": "egulias/email-validator", @@ -574,31 +573,31 @@ }, { "name": "fruitcake/php-cors", - "version": "v1.3.0", + "version": "v1.4.0", "source": { "type": "git", "url": "https://github.com/fruitcake/php-cors.git", - "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b" + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/3d158f36e7875e2f040f37bc0573956240a5a38b", - "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", "shasum": "" }, "require": { - "php": "^7.4|^8.0", - "symfony/http-foundation": "^4.4|^5.4|^6|^7" + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" }, "require-dev": { - "phpstan/phpstan": "^1.4", + "phpstan/phpstan": "^2", "phpunit/phpunit": "^9", - "squizlabs/php_codesniffer": "^3.5" + "squizlabs/php_codesniffer": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.2-dev" + "dev-master": "1.3-dev" } }, "autoload": { @@ -629,7 +628,7 @@ ], "support": { "issues": "https://github.com/fruitcake/php-cors/issues", - "source": "https://github.com/fruitcake/php-cors/tree/v1.3.0" + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" }, "funding": [ { @@ -641,28 +640,28 @@ "type": "github" } ], - "time": "2023-10-12T05:21:21+00:00" + "time": "2025-12-03T09:33:47+00:00" }, { "name": "graham-campbell/result-type", - "version": "v1.1.3", + "version": "v1.1.4", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3" + "phpoption/phpoption": "^1.9.5" }, "require-dev": { - "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" }, "type": "library", "autoload": { @@ -691,7 +690,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" }, "funding": [ { @@ -703,7 +702,7 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:45:45+00:00" + "time": "2025-12-27T19:43:20+00:00" }, { "name": "guzzlehttp/guzzle", @@ -916,16 +915,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.8.0", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "21dc724a0583619cd1652f673303492272778051" + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", - "reference": "21dc724a0583619cd1652f673303492272778051", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", "shasum": "" }, "require": { @@ -941,6 +940,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { @@ -1012,7 +1012,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.8.0" + "source": "https://github.com/guzzle/psr7/tree/2.9.0" }, "funding": [ { @@ -1028,7 +1028,7 @@ "type": "tidelift" } ], - "time": "2025-08-23T21:21:41+00:00" + "time": "2026-03-10T16:41:02+00:00" }, { "name": "guzzlehttp/uri-template", @@ -1322,16 +1322,16 @@ }, { "name": "laravel/framework", - "version": "v12.33.0", + "version": "v12.54.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "124efc5f09d4668a4dc13f94a1018c524a58bcb1" + "reference": "325497463e7599cd14224c422c6e5dd2fe832868" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/124efc5f09d4668a4dc13f94a1018c524a58bcb1", - "reference": "124efc5f09d4668a4dc13f94a1018c524a58bcb1", + "url": "https://api.github.com/repos/laravel/framework/zipball/325497463e7599cd14224c422c6e5dd2fe832868", + "reference": "325497463e7599cd14224c422c6e5dd2fe832868", "shasum": "" }, "require": { @@ -1352,7 +1352,7 @@ "guzzlehttp/uri-template": "^1.0", "laravel/prompts": "^0.3.0", "laravel/serializable-closure": "^1.3|^2.0", - "league/commonmark": "^2.7", + "league/commonmark": "^2.8.1", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", "league/uri": "^7.5.1", @@ -1419,6 +1419,7 @@ "illuminate/process": "self.version", "illuminate/queue": "self.version", "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", "illuminate/routing": "self.version", "illuminate/session": "self.version", "illuminate/support": "self.version", @@ -1443,13 +1444,13 @@ "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", "opis/json-schema": "^2.4.1", - "orchestra/testbench-core": "^10.6.5", + "orchestra/testbench-core": "^10.9.0", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", "predis/predis": "^2.3|^3.0", - "resend/resend-php": "^0.10.0", + "resend/resend-php": "^0.10.0|^1.0", "symfony/cache": "^7.2.0", "symfony/http-client": "^7.2.0", "symfony/psr-http-message-bridge": "^7.2.0", @@ -1483,7 +1484,7 @@ "predis/predis": "Required to use the predis connector (^2.3|^3.0).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", - "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", @@ -1505,6 +1506,7 @@ "src/Illuminate/Filesystem/functions.php", "src/Illuminate/Foundation/helpers.php", "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", "src/Illuminate/Support/functions.php", "src/Illuminate/Support/helpers.php" ], @@ -1513,7 +1515,8 @@ "Illuminate\\Support\\": [ "src/Illuminate/Macroable/", "src/Illuminate/Collections/", - "src/Illuminate/Conditionable/" + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" ] } }, @@ -1537,36 +1540,36 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-10-07T14:30:39+00:00" + "time": "2026-03-10T20:25:56+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.7", + "version": "v0.3.14", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "a1891d362714bc40c8d23b0b1d7090f022ea27cc" + "reference": "9f0e371244eedfe2ebeaa72c79c54bb5df6e0176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/a1891d362714bc40c8d23b0b1d7090f022ea27cc", - "reference": "a1891d362714bc40c8d23b0b1d7090f022ea27cc", + "url": "https://api.github.com/repos/laravel/prompts/zipball/9f0e371244eedfe2ebeaa72c79c54bb5df6e0176", + "reference": "9f0e371244eedfe2ebeaa72c79c54bb5df6e0176", "shasum": "" }, "require": { "composer-runtime-api": "^2.2", "ext-mbstring": "*", "php": "^8.1", - "symfony/console": "^6.2|^7.0" + "symfony/console": "^6.2|^7.0|^8.0" }, "conflict": { "illuminate/console": ">=10.17.0 <10.25.0", "laravel/framework": ">=10.17.0 <10.25.0" }, "require-dev": { - "illuminate/collections": "^10.0|^11.0|^12.0", + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.5", - "pestphp/pest": "^2.3|^3.4", + "pestphp/pest": "^2.3|^3.4|^4.0", "phpstan/phpstan": "^1.12.28", "phpstan/phpstan-mockery": "^1.1.3" }, @@ -1594,9 +1597,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.7" + "source": "https://github.com/laravel/prompts/tree/v0.3.14" }, - "time": "2025-09-19T13:47:56+00:00" + "time": "2026-03-01T09:02:38+00:00" }, { "name": "laravel/sanctum", @@ -1664,27 +1667,27 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.5", + "version": "v2.0.10", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "3832547db6e0e2f8bb03d4093857b378c66eceed" + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/3832547db6e0e2f8bb03d4093857b378c66eceed", - "reference": "3832547db6e0e2f8bb03d4093857b378c66eceed", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "nesbot/carbon": "^2.67|^3.0", - "pestphp/pest": "^2.36|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", "phpstan/phpstan": "^2.0", - "symfony/var-dumper": "^6.2.0|^7.0.0" + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" }, "type": "library", "extra": { @@ -1721,7 +1724,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2025-09-22T17:29:40+00:00" + "time": "2026-02-20T19:59:49+00:00" }, { "name": "laravel/tinker", @@ -1791,16 +1794,16 @@ }, { "name": "league/commonmark", - "version": "2.7.1", + "version": "2.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "10732241927d3971d28e7ea7b5712721fa2296ca" + "reference": "84b1ca48347efdbe775426f108622a42735a6579" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca", - "reference": "10732241927d3971d28e7ea7b5712721fa2296ca", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/84b1ca48347efdbe775426f108622a42735a6579", + "reference": "84b1ca48347efdbe775426f108622a42735a6579", "shasum": "" }, "require": { @@ -1825,9 +1828,9 @@ "phpstan/phpstan": "^1.8.2", "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 | ^7.0", - "symfony/process": "^5.4 | ^6.0 | ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", "unleashedtech/php-coding-standard": "^3.1.1", "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, @@ -1837,7 +1840,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.8-dev" + "dev-main": "2.9-dev" } }, "autoload": { @@ -1894,7 +1897,7 @@ "type": "tidelift" } ], - "time": "2025-07-20T12:47:49+00:00" + "time": "2026-03-05T21:37:03+00:00" }, { "name": "league/config", @@ -1980,16 +1983,16 @@ }, { "name": "league/flysystem", - "version": "3.30.0", + "version": "3.32.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "2203e3151755d874bb2943649dae1eb8533ac93e" + "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2203e3151755d874bb2943649dae1eb8533ac93e", - "reference": "2203e3151755d874bb2943649dae1eb8533ac93e", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725", + "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725", "shasum": "" }, "require": { @@ -2057,22 +2060,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.30.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.32.0" }, - "time": "2025-06-25T13:29:59+00:00" + "time": "2026-02-25T17:01:41+00:00" }, { "name": "league/flysystem-local", - "version": "3.30.0", + "version": "3.31.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10" + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/6691915f77c7fb69adfb87dcd550052dc184ee10", - "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", "shasum": "" }, "require": { @@ -2106,9 +2109,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" }, - "time": "2025-05-21T10:34:19+00:00" + "time": "2026-01-23T15:30:45+00:00" }, { "name": "league/mime-type-detection", @@ -2168,33 +2171,38 @@ }, { "name": "league/uri", - "version": "7.5.1", + "version": "7.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "81fb5145d2644324614cc532b28efd0215bda430" + "reference": "4436c6ec8d458e4244448b069cc572d088230b76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", - "reference": "81fb5145d2644324614cc532b28efd0215bda430", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", + "reference": "4436c6ec8d458e4244448b069cc572d088230b76", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.5", - "php": "^8.1" + "league/uri-interfaces": "^7.8", + "php": "^8.1", + "psr/http-factory": "^1" }, "conflict": { "league/uri-schemes": "^1.0" }, "suggest": { "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", "ext-fileinfo": "to create Data URI from file contennts", "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", - "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", - "league/uri-components": "Needed to easily manipulate URI objects components", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -2222,6 +2230,7 @@ "description": "URI manipulation library", "homepage": "https://uri.thephpleague.com", "keywords": [ + "URN", "data-uri", "file-uri", "ftp", @@ -2234,9 +2243,11 @@ "psr-7", "query-string", "querystring", + "rfc2141", "rfc3986", "rfc3987", "rfc6570", + "rfc8141", "uri", "uri-template", "url", @@ -2246,7 +2257,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.5.1" + "source": "https://github.com/thephpleague/uri/tree/7.8.0" }, "funding": [ { @@ -2254,26 +2265,25 @@ "type": "github" } ], - "time": "2024-12-08T08:40:02+00:00" + "time": "2026-01-14T17:24:56+00:00" }, { "name": "league/uri-interfaces", - "version": "7.5.0", + "version": "7.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", "shasum": "" }, "require": { "ext-filter": "*", "php": "^8.1", - "psr/http-factory": "^1", "psr/http-message": "^1.1 || ^2.0" }, "suggest": { @@ -2281,6 +2291,7 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -2305,7 +2316,7 @@ "homepage": "https://nyamsprod.com" } ], - "description": "Common interfaces and classes for URI representation and interaction", + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", "homepage": "https://uri.thephpleague.com", "keywords": [ "data-uri", @@ -2330,7 +2341,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" }, "funding": [ { @@ -2338,7 +2349,7 @@ "type": "github" } ], - "time": "2024-12-08T08:18:47+00:00" + "time": "2026-01-15T06:54:53+00:00" }, { "name": "mobiledetect/mobiledetectlib", @@ -2407,16 +2418,16 @@ }, { "name": "monolog/monolog", - "version": "3.9.0", + "version": "3.10.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", "shasum": "" }, "require": { @@ -2434,7 +2445,7 @@ "graylog2/gelf-php": "^1.4.2 || ^2.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8", + "mongodb/mongodb": "^1.8 || ^2.0", "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.8", "phpstan/phpstan": "^2", @@ -2494,7 +2505,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" }, "funding": [ { @@ -2506,20 +2517,20 @@ "type": "tidelift" } ], - "time": "2025-03-24T10:02:05+00:00" + "time": "2026-01-02T08:56:05+00:00" }, { "name": "nesbot/carbon", - "version": "3.10.3", + "version": "3.11.2", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f" + "reference": "57d696f4ec76d8560cc13b9d16ec01afc4379d04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f", - "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/57d696f4ec76d8560cc13b9d16ec01afc4379d04", + "reference": "57d696f4ec76d8560cc13b9d16ec01afc4379d04", "shasum": "" }, "require": { @@ -2527,9 +2538,9 @@ "ext-json": "*", "php": "^8.1", "psr/clock": "^1.0", - "symfony/clock": "^6.3.12 || ^7.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", "symfony/polyfill-mbstring": "^1.0", - "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0" + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" }, "provide": { "psr/clock-implementation": "1.0" @@ -2543,7 +2554,7 @@ "phpstan/extension-installer": "^1.4.3", "phpstan/phpstan": "^2.1.22", "phpunit/phpunit": "^10.5.53", - "squizlabs/php_codesniffer": "^3.13.4" + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" }, "bin": [ "bin/carbon" @@ -2586,14 +2597,14 @@ } ], "description": "An API extension for DateTime that supports 281 different languages.", - "homepage": "https://carbon.nesbot.com", + "homepage": "https://carbonphp.github.io/carbon/", "keywords": [ "date", "datetime", "time" ], "support": { - "docs": "https://carbon.nesbot.com/docs", + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", "issues": "https://github.com/CarbonPHP/carbon/issues", "source": "https://github.com/CarbonPHP/carbon" }, @@ -2611,29 +2622,31 @@ "type": "tidelift" } ], - "time": "2025-09-06T13:39:36+00:00" + "time": "2026-03-10T21:43:48+00:00" }, { "name": "nette/schema", - "version": "v1.3.2", + "version": "v1.3.5", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "da801d52f0354f70a638673c4a0f04e16529431d" + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d", - "reference": "da801d52f0354f70a638673c4a0f04e16529431d", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", "shasum": "" }, "require": { "nette/utils": "^4.0", - "php": "8.1 - 8.4" + "php": "8.1 - 8.5" }, "require-dev": { - "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^1.0", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -2643,6 +2656,9 @@ } }, "autoload": { + "psr-4": { + "Nette\\": "src" + }, "classmap": [ "src/" ] @@ -2671,26 +2687,26 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.2" + "source": "https://github.com/nette/schema/tree/v1.3.5" }, - "time": "2024-10-06T23:10:23+00:00" + "time": "2026-02-23T03:47:12+00:00" }, { "name": "nette/utils", - "version": "v4.0.8", + "version": "v4.1.3", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede" + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/c930ca4e3cf4f17dcfb03037703679d2396d2ede", - "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede", + "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", "shasum": "" }, "require": { - "php": "8.0 - 8.5" + "php": "8.2 - 8.5" }, "conflict": { "nette/finder": "<3", @@ -2698,8 +2714,10 @@ }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.5", - "phpstan/phpstan-nette": "^2.0@stable", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -2713,7 +2731,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-master": "4.1-dev" } }, "autoload": { @@ -2760,9 +2778,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.0.8" + "source": "https://github.com/nette/utils/tree/v4.1.3" }, - "time": "2025-08-06T21:43:34+00:00" + "time": "2026-02-13T03:05:33+00:00" }, { "name": "nikic/php-parser", @@ -2824,31 +2842,31 @@ }, { "name": "nunomaduro/termwind", - "version": "v2.3.1", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "dfa08f390e509967a15c22493dc0bac5733d9123" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dfa08f390e509967a15c22493dc0bac5733d9123", - "reference": "dfa08f390e509967a15c22493dc0bac5733d9123", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.2.6" + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^11.44.7", - "laravel/pint": "^1.22.0", + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.2", - "phpstan/phpstan": "^1.12.25", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.2.6", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -2880,7 +2898,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -2891,7 +2909,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.1" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -2907,7 +2925,7 @@ "type": "github" } ], - "time": "2025-05-08T08:14:37+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "nyholm/psr7", @@ -3037,16 +3055,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.4", + "version": "1.9.5", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d" + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", - "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", "shasum": "" }, "require": { @@ -3096,7 +3114,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.4" + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" }, "funding": [ { @@ -3108,7 +3126,7 @@ "type": "tidelift" } ], - "time": "2025-08-21T11:53:16+00:00" + "time": "2025-12-27T19:41:33+00:00" }, { "name": "psr/cache", @@ -3771,20 +3789,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.1", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" + "reference": "8429c78ca35a09f27565311b98101e2826affde0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -3843,9 +3861,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.1" + "source": "https://github.com/ramsey/uuid/tree/4.9.2" }, - "time": "2025-09-04T20:59:21+00:00" + "time": "2025-12-14T04:43:48+00:00" }, { "name": "rcrowe/twigbridge", @@ -4194,16 +4212,16 @@ }, { "name": "symfony/clock", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", - "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110", + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110", "shasum": "" }, "require": { @@ -4248,7 +4266,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.3.0" + "source": "https://github.com/symfony/clock/tree/v7.4.0" }, "funding": [ { @@ -4259,25 +4277,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-11-12T15:39:26+00:00" }, { "name": "symfony/console", - "version": "v7.3.4", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db" + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/2b9c5fafbac0399a20a2e82429e2bd735dcfb7db", - "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db", + "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", "shasum": "" }, "require": { @@ -4285,7 +4307,7 @@ "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2" + "symfony/string": "^7.2|^8.0" }, "conflict": { "symfony/dependency-injection": "<6.4", @@ -4299,16 +4321,16 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4342,7 +4364,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.4" + "source": "https://github.com/symfony/console/tree/v7.4.7" }, "funding": [ { @@ -4362,20 +4384,20 @@ "type": "tidelift" } ], - "time": "2025-09-22T15:31:00+00:00" + "time": "2026-03-06T14:06:20+00:00" }, { "name": "symfony/css-selector", - "version": "v7.3.0", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2" + "reference": "2e7c52c647b406e2107dd867db424a4dbac91864" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2", - "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/2e7c52c647b406e2107dd867db424a4dbac91864", + "reference": "2e7c52c647b406e2107dd867db424a4dbac91864", "shasum": "" }, "require": { @@ -4411,7 +4433,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.3.0" + "source": "https://github.com/symfony/css-selector/tree/v7.4.6" }, "funding": [ { @@ -4422,12 +4444,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-02-17T07:53:42+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4498,32 +4524,33 @@ }, { "name": "symfony/error-handler", - "version": "v7.3.4", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4" + "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/99f81bc944ab8e5dae4f21b4ca9972698bbad0e4", - "reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "conflict": { "symfony/deprecation-contracts": "<2.5", "symfony/http-kernel": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ @@ -4555,7 +4582,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.3.4" + "source": "https://github.com/symfony/error-handler/tree/v7.4.4" }, "funding": [ { @@ -4575,20 +4602,20 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2026-01-20T16:42:42+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.3.3", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191" + "reference": "dc2c0eba1af673e736bb851d747d266108aea746" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b7dc69e71de420ac04bc9ab830cf3ffebba48191", - "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746", + "reference": "dc2c0eba1af673e736bb851d747d266108aea746", "shasum": "" }, "require": { @@ -4605,13 +4632,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/error-handler": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0" + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4639,7 +4667,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.3" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4" }, "funding": [ { @@ -4659,7 +4687,7 @@ "type": "tidelift" } ], - "time": "2025-08-13T11:49:31+00:00" + "time": "2026-01-05T11:45:34+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4739,23 +4767,23 @@ }, { "name": "symfony/finder", - "version": "v7.3.2", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", + "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4783,7 +4811,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.2" + "source": "https://github.com/symfony/finder/tree/v7.4.6" }, "funding": [ { @@ -4803,27 +4831,26 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2026-01-29T09:40:50+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.3.4", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "c061c7c18918b1b64268771aad04b40be41dd2e6" + "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/c061c7c18918b1b64268771aad04b40be41dd2e6", - "reference": "c061c7c18918b1b64268771aad04b40be41dd2e6", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81", + "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-mbstring": "~1.1", - "symfony/polyfill-php83": "^1.27" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" }, "conflict": { "doctrine/dbal": "<3.6", @@ -4832,13 +4859,13 @@ "require-dev": { "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5", - "symfony/clock": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/mime": "^6.4|^7.0", - "symfony/rate-limiter": "^6.4|^7.0" + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4866,7 +4893,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.4" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.7" }, "funding": [ { @@ -4886,29 +4913,29 @@ "type": "tidelift" } ], - "time": "2025-09-16T08:38:17+00:00" + "time": "2026-03-06T13:15:18+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.4", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "b796dffea7821f035047235e076b60ca2446e3cf" + "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/b796dffea7821f035047235e076b60ca2446e3cf", - "reference": "b796dffea7821f035047235e076b60ca2446e3cf", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1", + "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0", - "symfony/event-dispatcher": "^7.3", - "symfony/http-foundation": "^7.3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { @@ -4918,6 +4945,7 @@ "symfony/console": "<6.4", "symfony/dependency-injection": "<6.4", "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", "symfony/form": "<6.4", "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", @@ -4935,27 +4963,27 @@ }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^6.4|^7.0", - "symfony/clock": "^6.4|^7.0", - "symfony/config": "^6.4|^7.0", - "symfony/console": "^6.4|^7.0", - "symfony/css-selector": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/dom-crawler": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/finder": "^6.4|^7.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^6.4|^7.0", - "symfony/property-access": "^7.1", - "symfony/routing": "^6.4|^7.0", - "symfony/serializer": "^7.1", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/translation": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^6.4|^7.0", - "symfony/validator": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0", - "symfony/var-exporter": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", "twig/twig": "^3.12" }, "type": "library", @@ -4984,7 +5012,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.3.4" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.7" }, "funding": [ { @@ -5004,20 +5032,20 @@ "type": "tidelift" } ], - "time": "2025-09-27T12:32:17+00:00" + "time": "2026-03-06T16:33:18+00:00" }, { "name": "symfony/mailer", - "version": "v7.3.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "ab97ef2f7acf0216955f5845484235113047a31d" + "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/ab97ef2f7acf0216955f5845484235113047a31d", - "reference": "ab97ef2f7acf0216955f5845484235113047a31d", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", "shasum": "" }, "require": { @@ -5025,8 +5053,8 @@ "php": ">=8.2", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/mime": "^7.2", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { @@ -5037,10 +5065,10 @@ "symfony/twig-bridge": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/twig-bridge": "^6.4|^7.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5068,7 +5096,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.4" + "source": "https://github.com/symfony/mailer/tree/v7.4.6" }, "funding": [ { @@ -5088,43 +5116,44 @@ "type": "tidelift" } ], - "time": "2025-09-17T05:51:54+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/mime", - "version": "v7.3.4", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35" + "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b1b828f69cbaf887fa835a091869e55df91d0e35", - "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35", + "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1", + "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<3.2.2", - "phpdocumentor/type-resolver": "<1.4.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/property-access": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0", - "symfony/serializer": "^6.4.3|^7.0.3" + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" }, "type": "library", "autoload": { @@ -5156,7 +5185,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.3.4" + "source": "https://github.com/symfony/mime/tree/v7.4.7" }, "funding": [ { @@ -5176,7 +5205,7 @@ "type": "tidelift" } ], - "time": "2025-09-16T08:38:17+00:00" + "time": "2026-03-05T15:24:09+00:00" }, { "name": "symfony/options-resolver", @@ -6080,16 +6109,16 @@ }, { "name": "symfony/process", - "version": "v7.3.4", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b" + "reference": "608476f4604102976d687c483ac63a79ba18cc97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b", - "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b", + "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", + "reference": "608476f4604102976d687c483ac63a79ba18cc97", "shasum": "" }, "require": { @@ -6121,7 +6150,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.3.4" + "source": "https://github.com/symfony/process/tree/v7.4.5" }, "funding": [ { @@ -6141,7 +6170,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2026-01-26T15:07:59+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -6228,16 +6257,16 @@ }, { "name": "symfony/routing", - "version": "v7.3.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c" + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/8dc648e159e9bac02b703b9fbd937f19ba13d07c", - "reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c", + "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", "shasum": "" }, "require": { @@ -6251,11 +6280,11 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/yaml": "^6.4|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6289,7 +6318,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.3.4" + "source": "https://github.com/symfony/routing/tree/v7.4.6" }, "funding": [ { @@ -6309,20 +6338,20 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -6376,7 +6405,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -6387,31 +6416,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-25T09:37:31+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "symfony/string", - "version": "v7.3.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f96476035142921000338bad71e5247fbc138872" + "reference": "9f209231affa85aa930a5e46e6eb03381424b30b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872", - "reference": "f96476035142921000338bad71e5247fbc138872", + "url": "https://api.github.com/repos/symfony/string/zipball/9f209231affa85aa930a5e46e6eb03381424b30b", + "reference": "9f209231affa85aa930a5e46e6eb03381424b30b", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-grapheme": "~1.33", "symfony/polyfill-intl-normalizer": "~1.0", "symfony/polyfill-mbstring": "~1.0" }, @@ -6419,11 +6453,11 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1", - "symfony/http-client": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6462,7 +6496,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.4" + "source": "https://github.com/symfony/string/tree/v7.4.6" }, "funding": [ { @@ -6482,27 +6516,27 @@ "type": "tidelift" } ], - "time": "2025-09-11T14:36:48+00:00" + "time": "2026-02-09T09:33:46+00:00" }, { "name": "symfony/translation", - "version": "v7.3.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "ec25870502d0c7072d086e8ffba1420c85965174" + "reference": "1888cf064399868af3784b9e043240f1d89d25ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/ec25870502d0c7072d086e8ffba1420c85965174", - "reference": "ec25870502d0c7072d086e8ffba1420c85965174", + "url": "https://api.github.com/repos/symfony/translation/zipball/1888cf064399868af3784b9e043240f1d89d25ce", + "reference": "1888cf064399868af3784b9e043240f1d89d25ce", "shasum": "" }, "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", - "symfony/translation-contracts": "^2.5|^3.0" + "symfony/translation-contracts": "^2.5.3|^3.3" }, "conflict": { "nikic/php-parser": "<5.0", @@ -6521,17 +6555,17 @@ "require-dev": { "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/console": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/finder": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^6.4|^7.0" + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6562,7 +6596,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.4" + "source": "https://github.com/symfony/translation/tree/v7.4.6" }, "funding": [ { @@ -6582,20 +6616,20 @@ "type": "tidelift" } ], - "time": "2025-09-07T11:39:36+00:00" + "time": "2026-02-17T07:53:42+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" + "reference": "65a8bc82080447fae78373aa10f8d13b38338977" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", - "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", "shasum": "" }, "require": { @@ -6644,7 +6678,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" }, "funding": [ { @@ -6655,25 +6689,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-27T08:32:26+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/uid", - "version": "v7.3.1", + "version": "v7.4.4", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb" + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/a69f69f3159b852651a6bf45a9fdd149520525bb", - "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb", + "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", "shasum": "" }, "require": { @@ -6681,7 +6719,7 @@ "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^6.4|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6718,7 +6756,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.3.1" + "source": "https://github.com/symfony/uid/tree/v7.4.4" }, "funding": [ { @@ -6729,25 +6767,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2026-01-03T23:30:35+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.3.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb" + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb", - "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", + "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", "shasum": "" }, "require": { @@ -6759,10 +6801,10 @@ "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/uid": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", "twig/twig": "^3.12" }, "bin": [ @@ -6801,7 +6843,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.4" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" }, "funding": [ { @@ -6821,27 +6863,27 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2026-02-15T10:53:20+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", - "version": "v2.3.0", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d" + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "php": "^7.4 || ^8.0", - "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "phpstan/phpstan": "^2.0", @@ -6874,9 +6916,9 @@ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" }, - "time": "2024-12-21T16:25:41+00:00" + "time": "2025-12-02T11:56:42+00:00" }, { "name": "twig/twig", @@ -6959,26 +7001,26 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + "reference": "955e7815d677a3eaa7075231212f2110983adecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.3", + "graham-campbell/result-type": "^1.1.4", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3", - "symfony/polyfill-ctype": "^1.24", - "symfony/polyfill-mbstring": "^1.24", - "symfony/polyfill-php80": "^1.24" + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -7027,7 +7069,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" }, "funding": [ { @@ -7039,7 +7081,7 @@ "type": "tidelift" } ], - "time": "2025-04-30T23:37:27+00:00" + "time": "2025-12-27T19:49:13+00:00" }, { "name": "voku/portable-ascii", @@ -7114,64 +7156,6 @@ } ], "time": "2024-11-21T01:49:47+00:00" - }, - { - "name": "webmozart/assert", - "version": "1.11.0", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "php": "^7.2 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<0.12.20", - "vimeo/psalm": "<4.6.1 || 4.6.2" - }, - "require-dev": { - "phpunit/phpunit": "^8.5.13" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.10-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.11.0" - }, - "time": "2022-06-03T18:03:27+00:00" } ], "packages-dev": [ @@ -7270,29 +7254,29 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -7312,9 +7296,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "fakerphp/faker", @@ -7564,34 +7548,34 @@ }, { "name": "laravel/boost", - "version": "v1.3.0", + "version": "v2.2.3", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "ef8800843efc581965c38393adb63ba336dc3979" + "reference": "44ab65a5455c2d6fceb71d6145f8d5d89c02d889" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/ef8800843efc581965c38393adb63ba336dc3979", - "reference": "ef8800843efc581965c38393adb63ba336dc3979", + "url": "https://api.github.com/repos/laravel/boost/zipball/44ab65a5455c2d6fceb71d6145f8d5d89c02d889", + "reference": "44ab65a5455c2d6fceb71d6145f8d5d89c02d889", "shasum": "" }, "require": { - "guzzlehttp/guzzle": "^7.10", - "illuminate/console": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/routing": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/support": "^10.49.0|^11.45.3|^12.28.1", - "laravel/mcp": "^0.2.0", - "laravel/prompts": "0.1.25|^0.3.6", - "laravel/roster": "^0.2.8", - "php": "^8.1" + "guzzlehttp/guzzle": "^7.9", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.5.1|^0.6.0", + "laravel/prompts": "^0.3.10", + "laravel/roster": "^0.5.0", + "php": "^8.2" }, "require-dev": { - "laravel/pint": "1.20", + "laravel/pint": "^1.27.0", "mockery/mockery": "^1.6.12", - "orchestra/testbench": "^8.36.0|^9.15.0|^10.6", - "pestphp/pest": "^2.36.0|^3.8.4", + "orchestra/testbench": "^9.15.0|^10.6|^11.0", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", "phpstan/phpstan": "^2.1.27", "rector/rector": "^2.1" }, @@ -7626,41 +7610,41 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-09-30T09:34:43+00:00" + "time": "2026-03-06T20:20:28+00:00" }, { "name": "laravel/mcp", - "version": "v0.2.1", + "version": "v0.6.2", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "0ecf0c04b20e5946ae080e8d67984d5c555174b0" + "reference": "f696e44735b95ff275392eab8ce5a3b4b42a2223" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/0ecf0c04b20e5946ae080e8d67984d5c555174b0", - "reference": "0ecf0c04b20e5946ae080e8d67984d5c555174b0", + "url": "https://api.github.com/repos/laravel/mcp/zipball/f696e44735b95ff275392eab8ce5a3b4b42a2223", + "reference": "f696e44735b95ff275392eab8ce5a3b4b42a2223", "shasum": "" }, "require": { "ext-json": "*", "ext-mbstring": "*", - "illuminate/console": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/container": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/http": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/json-schema": "^12.28.1", - "illuminate/routing": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/support": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/validation": "^10.49.0|^11.45.3|^12.28.1", - "php": "^8.1" + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2" }, "require-dev": { - "laravel/pint": "1.20.0", - "orchestra/testbench": "^8.36.0|^9.15.0|^10.6.0", - "pestphp/pest": "^2.36.0|^3.8.4|^4.1.0", + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", "phpstan/phpstan": "^2.1.27", - "rector/rector": "^2.1.7" + "rector/rector": "^2.2.4" }, "type": "library", "extra": { @@ -7699,7 +7683,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-09-24T15:48:16+00:00" + "time": "2026-03-10T20:00:23+00:00" }, { "name": "laravel/pint", @@ -7769,31 +7753,31 @@ }, { "name": "laravel/roster", - "version": "v0.2.8", + "version": "v0.5.1", "source": { "type": "git", "url": "https://github.com/laravel/roster.git", - "reference": "832a6db43743bf08a58691da207f977ec8dc43aa" + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/roster/zipball/832a6db43743bf08a58691da207f977ec8dc43aa", - "reference": "832a6db43743bf08a58691da207f977ec8dc43aa", + "url": "https://api.github.com/repos/laravel/roster/zipball/5089de7615f72f78e831590ff9d0435fed0102bb", + "reference": "5089de7615f72f78e831590ff9d0435fed0102bb", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2", - "symfony/yaml": "^6.4|^7.2" + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" }, "require-dev": { "laravel/pint": "^1.14", "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", "phpstan/phpstan": "^2.0" }, "type": "library", @@ -7826,7 +7810,7 @@ "issues": "https://github.com/laravel/roster/issues", "source": "https://github.com/laravel/roster" }, - "time": "2025-09-22T13:28:47+00:00" + "time": "2026-03-05T07:58:43+00:00" }, { "name": "laravel/sail", @@ -10355,28 +10339,28 @@ }, { "name": "symfony/yaml", - "version": "v7.3.3", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "d4f4a66866fe2451f61296924767280ab5732d9d" + "reference": "58751048de17bae71c5aa0d13cb19d79bca26391" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/d4f4a66866fe2451f61296924767280ab5732d9d", - "reference": "d4f4a66866fe2451f61296924767280ab5732d9d", + "url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391", + "reference": "58751048de17bae71c5aa0d13cb19d79bca26391", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "bin": [ "Resources/bin/yaml-lint" @@ -10407,7 +10391,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.3" + "source": "https://github.com/symfony/yaml/tree/v7.4.6" }, "funding": [ { @@ -10427,7 +10411,7 @@ "type": "tidelift" } ], - "time": "2025-08-27T11:34:33+00:00" + "time": "2026-02-09T09:33:46+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", @@ -10537,6 +10521,64 @@ } ], "time": "2024-03-03T12:36:25+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.12.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^7.2 || ^8.0" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.12.1" + }, + "time": "2025-10-29T15:56:20+00:00" } ], "aliases": [], @@ -10548,5 +10590,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/_api_app/opencode.json b/_api_app/opencode.json new file mode 100644 index 000000000..53e16f3d5 --- /dev/null +++ b/_api_app/opencode.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "laravel-boost": { + "type": "local", + "enabled": true, + "command": [ + "php", + "artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file From 298eca4c592e8cc3387a69cb6cf8949ae69dcea2 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Wed, 11 Mar 2026 20:01:59 +0200 Subject: [PATCH 02/21] Claude project root guidelines --- CLAUDE.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..13a976ffa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Berta is a file-based CMS (no database required for content storage). It has three distinct sub-applications that are developed and built independently: + +1. **`_api_app/`** — Laravel 12 API backend (PHP 8.2+) +2. **`editor/`** — Angular 20 admin editor (TypeScript) +3. **`engine/`** — Legacy PHP rendering engine with Gulp-built assets + +## Development Commands + +### Laravel API (`_api_app/`) +```bash +cd _api_app +composer install +php artisan test --compact # Run all tests +php artisan test --compact --filter=TestName # Run specific test +vendor/bin/pint --dirty # Format changed PHP files +npm run dev # Vite dev server +npm run build # Build assets +``` + +### Angular Editor (`editor/`) +```bash +cd editor +npm install +npm run dev # Watch mode (outputs to engine/dist) +npm run build # Production build (outputs to engine/dist) +npm test # Karma/Jasmine unit tests +``` + +### Legacy Engine Assets (root) +```bash +npm install +npm run dev # Gulp watch (compiles Sass for themes/templates) +npm run build # Gulp production build +``` + +## Architecture + +### How the Parts Connect + +- The **Angular editor** (`editor/`) compiles into `engine/dist/` — the legacy PHP engine serves these compiled assets to the browser. +- The **Laravel API** (`_api_app/`) exposes REST endpoints consumed by the Angular editor for CMS operations (site settings, sections, media, shop). +- The **legacy PHP engine** (`engine/`) handles frontend rendering of sites using file-based XML storage. It reads `.xml` files directly from the user's site directory. +- The Angular editor's **Twig templates** are bundled at build time via `editor/copy-twig-templates.mjs` and `editor/bundle-twig-templates.js` (prebuild step), then rendered client-side using the `twig` npm package. + +### Key Directories + +| Path | Purpose | +|------|---------| +| `_api_app/app/Sites/` | Site/section/entry management domain | +| `_api_app/app/Shop/` | E-commerce plugin | +| `_api_app/app/Plugins/` | Plugin system | +| `_api_app/app/Configuration/` | App configuration classes | +| `editor/src/` | Angular source (components, state, services) | +| `engine/_classes/` | Legacy PHP classes for site rendering | +| `engine/_lib/berta/` | CSS/JS assets bundled by Gulp | +| `_themes/` | Site themes (capetown, jaipur, kyoto, madrid, etc.) | +| `_templates/` | Email/system templates with SCSS | +| `_plugin_shop/` | Shop plugin PHP files | + +### State Management (Angular) + +The editor uses **NGXS** for state management. State files live in `editor/src/app/**/state/` alongside their actions. + +### Authentication + +Laravel Sanctum handles API auth. JWT tokens (Firebase JWT) are used for certain operations. + +## Laravel API Guidelines + +See `_api_app/CLAUDE.md` for detailed Laravel/PHP conventions, Pest testing rules, and Laravel Boost MCP tool usage. Those guidelines apply whenever working inside `_api_app/`. From 633f0971c9fd6eebffb3a440a7dd69c5729211d0 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Wed, 18 Mar 2026 13:30:42 +0200 Subject: [PATCH 03/21] AI assistant chat widget --- .../skills/ai-sdk-development/SKILL.md | 413 ++++++++++++++++++ .../skills/ai-sdk-development/SKILL.md | 413 ++++++++++++++++++ _api_app/.codex/config.toml | 8 + .../skills/ai-sdk-development/SKILL.md | 413 ++++++++++++++++++ _api_app/.env.example | 2 + .../skills/ai-sdk-development/SKILL.md | 413 ++++++++++++++++++ _api_app/AGENTS.md | 31 +- _api_app/CLAUDE.md | 31 +- .../app/Http/Controllers/StateController.php | 10 +- _api_app/app/Http/Middleware/Authenticate.php | 16 - .../app/Http/Middleware/SetupMiddleware.php | 2 +- _api_app/app/User/UserModel.php | 1 + _api_app/boost.json | 8 +- _api_app/bootstrap/providers.php | 7 +- _api_app/composer.json | 5 +- _api_app/composer.lock | 183 +++++++- _api_app/config/ai.php | 129 ++++++ _api_app/config/auth.php | 4 +- _api_app/config/sanctum.php | 9 +- _api_app/config/twigbridge.php | 4 +- _api_app/database/factories/UserFactory.php | 3 +- _api_app/opencode.json | 11 + .../tests/Feature/AiChatControllerTest.php | 163 +++++++ _api_app/tests/Pest.php | 4 +- editor/package-lock.json | 13 + editor/package.json | 1 + .../app/ai-assistant/ai-assistant.actions.ts | 22 + .../ai-assistant/ai-assistant.component.ts | 359 +++++++++++++++ .../app/ai-assistant/ai-assistant.module.ts | 13 + .../app/ai-assistant/ai-assistant.service.ts | 37 ++ .../app/ai-assistant/ai-assistant.state.ts | 196 +++++++++ editor/src/app/app.component.ts | 1 + editor/src/app/app.module.ts | 4 + editor/src/app/header/header.component.ts | 17 + editor/src/app/pipes/markdown.pipe.ts | 18 + editor/src/app/pipes/pipes.module.ts | 9 + .../src/app/pipes/{pipe.ts => safe.pipe.ts} | 0 .../default-template-rerender.service.ts | 1 + .../mashup-template-rerender.service.ts | 1 + .../messy/messy-template-rerender.service.ts | 1 + .../app/rerender/template-rerender.service.ts | 6 + editor/src/app/rerender/types/components.ts | 1 + .../white/white-template-rerender.service.ts | 1 + .../sites/sections/site-sections.module.ts | 6 +- .../app/sites/settings/site-settings.state.ts | 1 + editor/src/app/user/user.state.ts | 2 + 46 files changed, 2921 insertions(+), 72 deletions(-) create mode 100644 _api_app/.agents/skills/ai-sdk-development/SKILL.md create mode 100644 _api_app/.claude/skills/ai-sdk-development/SKILL.md create mode 100644 _api_app/.cursor/skills/ai-sdk-development/SKILL.md create mode 100644 _api_app/.github/skills/ai-sdk-development/SKILL.md create mode 100644 _api_app/config/ai.php create mode 100644 _api_app/tests/Feature/AiChatControllerTest.php create mode 100644 editor/src/app/ai-assistant/ai-assistant.actions.ts create mode 100644 editor/src/app/ai-assistant/ai-assistant.component.ts create mode 100644 editor/src/app/ai-assistant/ai-assistant.module.ts create mode 100644 editor/src/app/ai-assistant/ai-assistant.service.ts create mode 100644 editor/src/app/ai-assistant/ai-assistant.state.ts create mode 100644 editor/src/app/pipes/markdown.pipe.ts create mode 100644 editor/src/app/pipes/pipes.module.ts rename editor/src/app/pipes/{pipe.ts => safe.pipe.ts} (100%) diff --git a/_api_app/.agents/skills/ai-sdk-development/SKILL.md b/_api_app/.agents/skills/ai-sdk-development/SKILL.md new file mode 100644 index 000000000..d74e342ec --- /dev/null +++ b/_api_app/.agents/skills/ai-sdk-development/SKILL.md @@ -0,0 +1,413 @@ +--- +name: ai-sdk-development +description: Builds AI agents, generates text and chat responses, produces images, synthesizes audio, transcribes speech, generates vector embeddings, reranks documents, and manages files and vector stores using the Laravel AI SDK (laravel/ai). Supports structured output, streaming, tools, conversation memory, middleware, queueing, broadcasting, and provider failover. Use when building, editing, updating, debugging, or testing any AI functionality, including agents, LLMs, chatbots, text generation, image generation, audio, transcription, embeddings, RAG, similarity search, vector stores, prompting, structured output, or any AI provider (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). +--- + +# Developing with the Laravel AI SDK + +The Laravel AI SDK (`laravel/ai`) is the official AI package for Laravel, providing a unified API for agents, images, audio, transcription, embeddings, reranking, vector stores, and file management across multiple AI providers. + +## Searching the Documentation + +This package is new. Always search the documentation before implementing any feature. Never guess at APIs — the documentation is the single source of truth. + +- Use broad, simple queries that match the documentation section headings below. +- Do not add package names to queries — package information is shared automatically. Use `test agent fake`, not `laravel ai test agent fake`. +- Run multiple queries at once — the most relevant results are returned first. + +### Documentation Sections + +Use these section headings as query terms for accurate results: + +- Introduction, Installation, Configuration, Provider Support +- Agents: Prompting, Conversation Context, Structured Output, Attachments, Streaming, Broadcasting, Queueing, Tools, Provider Tools, Middleware, Anonymous Agents, Agent Configuration +- Images +- Audio (TTS) +- Transcription (STT) +- Embeddings: Querying Embeddings, Caching Embeddings +- Reranking +- Files +- Vector Stores: Adding Files to Stores +- Failover +- Testing: Agents, Images, Audio, Transcriptions, Embeddings, Reranking, Files, Vector Stores +- Events + +## Decision Workflow + +Determine the right entry point before writing code: + +Text generation or chat? → Agent class with `Promptable` trait +Chat with conversation history? → Agent + `Conversational` interface (manual) or `RemembersConversations` trait (automatic) +Structured JSON output? → Agent + `HasStructuredOutput` interface +Image generation? → `Image::of()->generate()` +Audio synthesis? → `Audio::of()->generate()` +Transcription? → `Transcription::fromPath()->generate()` +Embeddings? → `Embeddings::for()->generate()` +Reranking? → `Reranking::of()->rerank()` +File storage? → `Document::fromPath()->put()` +Vector stores? → `Stores::create()` + +## Basic Usage Examples + +### Agents + +```php +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Promptable; + +class SalesCoach implements Agent +{ + use Promptable; + + public function instructions(): string + { + return 'You are a sales coach.'; + } +} + +// Prompting +$response = (new SalesCoach)->prompt('Analyze this transcript...'); +echo $response->text; + +// Streaming (returns SSE response from a route) +return (new SalesCoach)->stream('Analyze this transcript...'); + +// Queueing +(new SalesCoach)->queue('Analyze this transcript...') + ->then(fn ($response) => /* ... */); + +// Anonymous agents +use function Laravel\Ai\{agent}; + +$response = agent(instructions: 'You are a helpful assistant.')->prompt('Hello'); +``` + +### Conversation Context + +Manual conversation history via the `Conversational` interface: + +```php +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Contracts\Conversational; +use Laravel\Ai\Messages\Message; +use Laravel\Ai\Promptable; + +class SalesCoach implements Agent, Conversational +{ + use Promptable; + + public function __construct(public User $user) {} + + public function instructions(): string { return 'You are a sales coach.'; } + + public function messages(): iterable + { + return History::where('user_id', $this->user->id) + ->latest()->limit(50)->get()->reverse() + ->map(fn ($m) => new Message($m->role, $m->content)) + ->all(); + } +} +``` + +Automatic conversation persistence via the `RemembersConversations` trait: + +```php +use Laravel\Ai\Concerns\RemembersConversations; +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Contracts\Conversational; +use Laravel\Ai\Promptable; + +class SalesCoach implements Agent, Conversational +{ + use Promptable, RemembersConversations; + + public function instructions(): string { return 'You are a sales coach.'; } +} + +// Start a new conversation +$response = (new SalesCoach)->forUser($user)->prompt('Hello!'); +$conversationId = $response->conversationId; + +// Continue an existing conversation +$response = (new SalesCoach)->continue($conversationId, as: $user)->prompt('Tell me more.'); +``` + +### Structured Output + +```php +use Illuminate\Contracts\JsonSchema\JsonSchema; +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Contracts\HasStructuredOutput; +use Laravel\Ai\Promptable; + +class Reviewer implements Agent, HasStructuredOutput +{ + use Promptable; + + public function instructions(): string { return 'Review and score content.'; } + + public function schema(JsonSchema $schema): array + { + return [ + 'feedback' => $schema->string()->required(), + 'score' => $schema->integer()->min(1)->max(10)->required(), + ]; + } +} + +$response = (new Reviewer)->prompt('Review this...'); +echo $response['score']; // Access like an array +``` + +### Images + +```php +use Laravel\Ai\Image; + +$image = Image::of('A sunset over mountains') + ->landscape() + ->quality('high') + ->generate(); + +$path = $image->store(); // Store to default disk +``` + +### Audio + +```php +use Laravel\Ai\Audio; + +$audio = Audio::of('Hello from Laravel.') + ->female() + ->instructions('Speak warmly') + ->generate(); + +$path = $audio->store(); +``` + +### Transcription + +```php +use Laravel\Ai\Transcription; + +$transcript = Transcription::fromStorage('audio.mp3') + ->diarize() + ->generate(); + +echo (string) $transcript; +``` + +### Embeddings + +```php +use Laravel\Ai\Embeddings; +use Illuminate\Support\Str; + +$response = Embeddings::for(['Text one', 'Text two']) + ->dimensions(1536) + ->cache() + ->generate(); + +// Single string via Stringable +$embedding = Str::of('Napa Valley has great wine.')->toEmbeddings(); +``` + +### Reranking + +```php +use Laravel\Ai\Reranking; + +$response = Reranking::of(['Django is Python.', 'Laravel is PHP.', 'React is JS.']) + ->limit(5) + ->rerank('PHP frameworks'); + +$response->first()->document; // "Laravel is PHP." +``` + +### Files and Vector Stores + +```php +use Laravel\Ai\Files\Document; +use Laravel\Ai\Stores; + +// Store a file with the provider +$file = Document::fromPath('/path/to/doc.pdf')->put(); + +// Create a vector store and add files +$store = Stores::create('Knowledge Base'); +$store->add($file->id); +$store->add(Document::fromStorage('manual.pdf')); // Store + add in one step +``` + +## Agent Configuration + +### PHP Attributes + +```php +use Laravel\Ai\Attributes\{Provider, MaxSteps, MaxTokens, Temperature, Timeout}; + +#[Provider('anthropic')] +#[MaxSteps(10)] +#[MaxTokens(4096)] +#[Temperature(0.7)] +#[Timeout(120)] +class MyAgent implements Agent +{ + use Promptable; + // ... +} +``` + +The `#[UseCheapestModel]` and `#[UseSmartestModel]` attributes are also available for automatic model selection. + +### Tools + +Implement the `HasTools` interface and scaffold tools with `php artisan make:tool`: + +```php +use Laravel\Ai\Contracts\HasTools; + +class MyAgent implements Agent, HasTools +{ + use Promptable; + + public function tools(): iterable + { + return [new MyCustomTool]; + } +} +``` + +### Provider Tools + +```php +use Laravel\Ai\Providers\Tools\{WebSearch, WebFetch, FileSearch}; + +public function tools(): iterable +{ + return [ + (new WebSearch)->max(5)->allow(['laravel.com']), + new WebFetch, + new FileSearch(stores: ['store_id']), + ]; +} +``` + +### Conversation Memory + +```php +use Laravel\Ai\Concerns\RemembersConversations; +use Laravel\Ai\Contracts\Conversational; + +class ChatBot implements Agent, Conversational +{ + use Promptable, RemembersConversations; + // ... +} + +$response = (new ChatBot)->forUser($user)->prompt('Hello!'); +$response = (new ChatBot)->continue($conversationId, as: $user)->prompt('More...'); +``` + +### Failover + +```php +$response = (new MyAgent)->prompt('Hello', provider: ['openai', 'anthropic']); +``` + +## Testing and Faking + +Each capability supports `fake()` with assertions: + +```php +use App\Ai\Agents\SalesCoach; +use Laravel\Ai\{Image, Audio, Transcription, Embeddings, Reranking, Files, Stores}; + +// Agents +SalesCoach::fake(['Response 1', 'Response 2']); +SalesCoach::assertPrompted('query'); +SalesCoach::assertNotPrompted('query'); +SalesCoach::assertNeverPrompted(); +SalesCoach::fake()->preventStrayPrompts(); + +// Images +Image::fake(); +Image::assertGenerated(fn ($prompt) => $prompt->contains('sunset')); +Image::assertNothingGenerated(); + +// Audio +Audio::fake(); +Audio::assertGenerated(fn ($prompt) => $prompt->contains('Hello')); + +// Transcription +Transcription::fake(['Transcribed text.']); +Transcription::assertGenerated(fn ($prompt) => $prompt->isDiarized()); + +// Embeddings +Embeddings::fake(); +Embeddings::assertGenerated(fn ($prompt) => $prompt->contains('Laravel')); + +// Reranking +Reranking::fake(); +Reranking::assertReranked(fn ($prompt) => $prompt->contains('PHP')); + +// Files +Files::fake(); +Files::assertStored(fn ($file) => $file->mimeType() === 'text/plain'); + +// Stores +Stores::fake(); +Stores::assertCreated('Knowledge Base'); +$store = Stores::get('id'); +$store->assertAdded('file_id'); +``` + +## Key Patterns + +- Namespace: `Laravel\Ai\` +- Package: `composer require laravel/ai` +- Agent pattern: Implement the `Agent` interface and use the `Promptable` trait +- Optional interfaces: `HasTools`, `HasMiddleware`, `HasStructuredOutput`, `Conversational` +- Entry-point classes: `Image`, `Audio`, `Transcription`, `Embeddings`, `Reranking`, `Stores` +- Artisan commands: `php artisan make:agent`, `php artisan make:tool` +- Global helper: `agent()` for anonymous agents + +## Common Pitfalls + +### Wrong Namespace + +The namespace is `Laravel\Ai`, not `Illuminate\Ai` or `Laravel\AI`. + +```php +// Correct +use Laravel\Ai\Image; +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Promptable; + +// Wrong — these do not exist +use Illuminate\Ai\Image; +use Laravel\AI\Agent; +``` + +### Unsupported Provider Capability + +Calling a capability not supported by a provider throws a `LogicException`. Refer to the provider support table below. + +### Never Use Prism Directly + +Use agents and entry-point classes (`Image`, `Audio`, etc.) — not `Prism::text()` directly. The AI SDK wraps Prism internally. + +## Provider Support + +| Provider | Text | Image | Audio | STT | Embeddings | Reranking | Files | Stores | +| ---------- | ---- | ----- | ----- | --- | ---------- | --------- | ----- | ------ | +| OpenAI | Y | Y | Y | Y | Y | - | Y | Y | +| Anthropic | Y | - | - | - | - | - | Y | - | +| Gemini | Y | Y | - | - | Y | - | Y | Y | +| xAI | Y | Y | - | - | - | - | - | - | +| Groq | Y | - | - | - | - | - | - | - | +| OpenRouter | Y | - | - | - | - | - | - | - | +| ElevenLabs | - | - | Y | Y | - | - | - | - | +| Cohere | - | - | - | - | Y | Y | - | - | +| Jina | - | - | - | - | Y | Y | - | - | \ No newline at end of file diff --git a/_api_app/.claude/skills/ai-sdk-development/SKILL.md b/_api_app/.claude/skills/ai-sdk-development/SKILL.md new file mode 100644 index 000000000..d74e342ec --- /dev/null +++ b/_api_app/.claude/skills/ai-sdk-development/SKILL.md @@ -0,0 +1,413 @@ +--- +name: ai-sdk-development +description: Builds AI agents, generates text and chat responses, produces images, synthesizes audio, transcribes speech, generates vector embeddings, reranks documents, and manages files and vector stores using the Laravel AI SDK (laravel/ai). Supports structured output, streaming, tools, conversation memory, middleware, queueing, broadcasting, and provider failover. Use when building, editing, updating, debugging, or testing any AI functionality, including agents, LLMs, chatbots, text generation, image generation, audio, transcription, embeddings, RAG, similarity search, vector stores, prompting, structured output, or any AI provider (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). +--- + +# Developing with the Laravel AI SDK + +The Laravel AI SDK (`laravel/ai`) is the official AI package for Laravel, providing a unified API for agents, images, audio, transcription, embeddings, reranking, vector stores, and file management across multiple AI providers. + +## Searching the Documentation + +This package is new. Always search the documentation before implementing any feature. Never guess at APIs — the documentation is the single source of truth. + +- Use broad, simple queries that match the documentation section headings below. +- Do not add package names to queries — package information is shared automatically. Use `test agent fake`, not `laravel ai test agent fake`. +- Run multiple queries at once — the most relevant results are returned first. + +### Documentation Sections + +Use these section headings as query terms for accurate results: + +- Introduction, Installation, Configuration, Provider Support +- Agents: Prompting, Conversation Context, Structured Output, Attachments, Streaming, Broadcasting, Queueing, Tools, Provider Tools, Middleware, Anonymous Agents, Agent Configuration +- Images +- Audio (TTS) +- Transcription (STT) +- Embeddings: Querying Embeddings, Caching Embeddings +- Reranking +- Files +- Vector Stores: Adding Files to Stores +- Failover +- Testing: Agents, Images, Audio, Transcriptions, Embeddings, Reranking, Files, Vector Stores +- Events + +## Decision Workflow + +Determine the right entry point before writing code: + +Text generation or chat? → Agent class with `Promptable` trait +Chat with conversation history? → Agent + `Conversational` interface (manual) or `RemembersConversations` trait (automatic) +Structured JSON output? → Agent + `HasStructuredOutput` interface +Image generation? → `Image::of()->generate()` +Audio synthesis? → `Audio::of()->generate()` +Transcription? → `Transcription::fromPath()->generate()` +Embeddings? → `Embeddings::for()->generate()` +Reranking? → `Reranking::of()->rerank()` +File storage? → `Document::fromPath()->put()` +Vector stores? → `Stores::create()` + +## Basic Usage Examples + +### Agents + +```php +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Promptable; + +class SalesCoach implements Agent +{ + use Promptable; + + public function instructions(): string + { + return 'You are a sales coach.'; + } +} + +// Prompting +$response = (new SalesCoach)->prompt('Analyze this transcript...'); +echo $response->text; + +// Streaming (returns SSE response from a route) +return (new SalesCoach)->stream('Analyze this transcript...'); + +// Queueing +(new SalesCoach)->queue('Analyze this transcript...') + ->then(fn ($response) => /* ... */); + +// Anonymous agents +use function Laravel\Ai\{agent}; + +$response = agent(instructions: 'You are a helpful assistant.')->prompt('Hello'); +``` + +### Conversation Context + +Manual conversation history via the `Conversational` interface: + +```php +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Contracts\Conversational; +use Laravel\Ai\Messages\Message; +use Laravel\Ai\Promptable; + +class SalesCoach implements Agent, Conversational +{ + use Promptable; + + public function __construct(public User $user) {} + + public function instructions(): string { return 'You are a sales coach.'; } + + public function messages(): iterable + { + return History::where('user_id', $this->user->id) + ->latest()->limit(50)->get()->reverse() + ->map(fn ($m) => new Message($m->role, $m->content)) + ->all(); + } +} +``` + +Automatic conversation persistence via the `RemembersConversations` trait: + +```php +use Laravel\Ai\Concerns\RemembersConversations; +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Contracts\Conversational; +use Laravel\Ai\Promptable; + +class SalesCoach implements Agent, Conversational +{ + use Promptable, RemembersConversations; + + public function instructions(): string { return 'You are a sales coach.'; } +} + +// Start a new conversation +$response = (new SalesCoach)->forUser($user)->prompt('Hello!'); +$conversationId = $response->conversationId; + +// Continue an existing conversation +$response = (new SalesCoach)->continue($conversationId, as: $user)->prompt('Tell me more.'); +``` + +### Structured Output + +```php +use Illuminate\Contracts\JsonSchema\JsonSchema; +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Contracts\HasStructuredOutput; +use Laravel\Ai\Promptable; + +class Reviewer implements Agent, HasStructuredOutput +{ + use Promptable; + + public function instructions(): string { return 'Review and score content.'; } + + public function schema(JsonSchema $schema): array + { + return [ + 'feedback' => $schema->string()->required(), + 'score' => $schema->integer()->min(1)->max(10)->required(), + ]; + } +} + +$response = (new Reviewer)->prompt('Review this...'); +echo $response['score']; // Access like an array +``` + +### Images + +```php +use Laravel\Ai\Image; + +$image = Image::of('A sunset over mountains') + ->landscape() + ->quality('high') + ->generate(); + +$path = $image->store(); // Store to default disk +``` + +### Audio + +```php +use Laravel\Ai\Audio; + +$audio = Audio::of('Hello from Laravel.') + ->female() + ->instructions('Speak warmly') + ->generate(); + +$path = $audio->store(); +``` + +### Transcription + +```php +use Laravel\Ai\Transcription; + +$transcript = Transcription::fromStorage('audio.mp3') + ->diarize() + ->generate(); + +echo (string) $transcript; +``` + +### Embeddings + +```php +use Laravel\Ai\Embeddings; +use Illuminate\Support\Str; + +$response = Embeddings::for(['Text one', 'Text two']) + ->dimensions(1536) + ->cache() + ->generate(); + +// Single string via Stringable +$embedding = Str::of('Napa Valley has great wine.')->toEmbeddings(); +``` + +### Reranking + +```php +use Laravel\Ai\Reranking; + +$response = Reranking::of(['Django is Python.', 'Laravel is PHP.', 'React is JS.']) + ->limit(5) + ->rerank('PHP frameworks'); + +$response->first()->document; // "Laravel is PHP." +``` + +### Files and Vector Stores + +```php +use Laravel\Ai\Files\Document; +use Laravel\Ai\Stores; + +// Store a file with the provider +$file = Document::fromPath('/path/to/doc.pdf')->put(); + +// Create a vector store and add files +$store = Stores::create('Knowledge Base'); +$store->add($file->id); +$store->add(Document::fromStorage('manual.pdf')); // Store + add in one step +``` + +## Agent Configuration + +### PHP Attributes + +```php +use Laravel\Ai\Attributes\{Provider, MaxSteps, MaxTokens, Temperature, Timeout}; + +#[Provider('anthropic')] +#[MaxSteps(10)] +#[MaxTokens(4096)] +#[Temperature(0.7)] +#[Timeout(120)] +class MyAgent implements Agent +{ + use Promptable; + // ... +} +``` + +The `#[UseCheapestModel]` and `#[UseSmartestModel]` attributes are also available for automatic model selection. + +### Tools + +Implement the `HasTools` interface and scaffold tools with `php artisan make:tool`: + +```php +use Laravel\Ai\Contracts\HasTools; + +class MyAgent implements Agent, HasTools +{ + use Promptable; + + public function tools(): iterable + { + return [new MyCustomTool]; + } +} +``` + +### Provider Tools + +```php +use Laravel\Ai\Providers\Tools\{WebSearch, WebFetch, FileSearch}; + +public function tools(): iterable +{ + return [ + (new WebSearch)->max(5)->allow(['laravel.com']), + new WebFetch, + new FileSearch(stores: ['store_id']), + ]; +} +``` + +### Conversation Memory + +```php +use Laravel\Ai\Concerns\RemembersConversations; +use Laravel\Ai\Contracts\Conversational; + +class ChatBot implements Agent, Conversational +{ + use Promptable, RemembersConversations; + // ... +} + +$response = (new ChatBot)->forUser($user)->prompt('Hello!'); +$response = (new ChatBot)->continue($conversationId, as: $user)->prompt('More...'); +``` + +### Failover + +```php +$response = (new MyAgent)->prompt('Hello', provider: ['openai', 'anthropic']); +``` + +## Testing and Faking + +Each capability supports `fake()` with assertions: + +```php +use App\Ai\Agents\SalesCoach; +use Laravel\Ai\{Image, Audio, Transcription, Embeddings, Reranking, Files, Stores}; + +// Agents +SalesCoach::fake(['Response 1', 'Response 2']); +SalesCoach::assertPrompted('query'); +SalesCoach::assertNotPrompted('query'); +SalesCoach::assertNeverPrompted(); +SalesCoach::fake()->preventStrayPrompts(); + +// Images +Image::fake(); +Image::assertGenerated(fn ($prompt) => $prompt->contains('sunset')); +Image::assertNothingGenerated(); + +// Audio +Audio::fake(); +Audio::assertGenerated(fn ($prompt) => $prompt->contains('Hello')); + +// Transcription +Transcription::fake(['Transcribed text.']); +Transcription::assertGenerated(fn ($prompt) => $prompt->isDiarized()); + +// Embeddings +Embeddings::fake(); +Embeddings::assertGenerated(fn ($prompt) => $prompt->contains('Laravel')); + +// Reranking +Reranking::fake(); +Reranking::assertReranked(fn ($prompt) => $prompt->contains('PHP')); + +// Files +Files::fake(); +Files::assertStored(fn ($file) => $file->mimeType() === 'text/plain'); + +// Stores +Stores::fake(); +Stores::assertCreated('Knowledge Base'); +$store = Stores::get('id'); +$store->assertAdded('file_id'); +``` + +## Key Patterns + +- Namespace: `Laravel\Ai\` +- Package: `composer require laravel/ai` +- Agent pattern: Implement the `Agent` interface and use the `Promptable` trait +- Optional interfaces: `HasTools`, `HasMiddleware`, `HasStructuredOutput`, `Conversational` +- Entry-point classes: `Image`, `Audio`, `Transcription`, `Embeddings`, `Reranking`, `Stores` +- Artisan commands: `php artisan make:agent`, `php artisan make:tool` +- Global helper: `agent()` for anonymous agents + +## Common Pitfalls + +### Wrong Namespace + +The namespace is `Laravel\Ai`, not `Illuminate\Ai` or `Laravel\AI`. + +```php +// Correct +use Laravel\Ai\Image; +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Promptable; + +// Wrong — these do not exist +use Illuminate\Ai\Image; +use Laravel\AI\Agent; +``` + +### Unsupported Provider Capability + +Calling a capability not supported by a provider throws a `LogicException`. Refer to the provider support table below. + +### Never Use Prism Directly + +Use agents and entry-point classes (`Image`, `Audio`, etc.) — not `Prism::text()` directly. The AI SDK wraps Prism internally. + +## Provider Support + +| Provider | Text | Image | Audio | STT | Embeddings | Reranking | Files | Stores | +| ---------- | ---- | ----- | ----- | --- | ---------- | --------- | ----- | ------ | +| OpenAI | Y | Y | Y | Y | Y | - | Y | Y | +| Anthropic | Y | - | - | - | - | - | Y | - | +| Gemini | Y | Y | - | - | Y | - | Y | Y | +| xAI | Y | Y | - | - | - | - | - | - | +| Groq | Y | - | - | - | - | - | - | - | +| OpenRouter | Y | - | - | - | - | - | - | - | +| ElevenLabs | - | - | Y | Y | - | - | - | - | +| Cohere | - | - | - | - | Y | Y | - | - | +| Jina | - | - | - | - | Y | Y | - | - | \ No newline at end of file diff --git a/_api_app/.codex/config.toml b/_api_app/.codex/config.toml index 722d917ec..9f0141cdc 100644 --- a/_api_app/.codex/config.toml +++ b/_api_app/.codex/config.toml @@ -2,3 +2,11 @@ command = "php" args = ["artisan", "boost:mcp"] cwd = "/Users/uldis/projects/berta/berta/_api_app" + +[mcp_servers.herd] +command = "php" +args = ["/Applications/Herd.app/Contents/Resources/herd-mcp.phar"] +cwd = "/Users/uldis/projects/berta/berta/_api_app" + +[mcp_servers.herd.env] +SITE_PATH = "/Users/uldis/projects/berta/berta/_api_app" diff --git a/_api_app/.cursor/skills/ai-sdk-development/SKILL.md b/_api_app/.cursor/skills/ai-sdk-development/SKILL.md new file mode 100644 index 000000000..d74e342ec --- /dev/null +++ b/_api_app/.cursor/skills/ai-sdk-development/SKILL.md @@ -0,0 +1,413 @@ +--- +name: ai-sdk-development +description: Builds AI agents, generates text and chat responses, produces images, synthesizes audio, transcribes speech, generates vector embeddings, reranks documents, and manages files and vector stores using the Laravel AI SDK (laravel/ai). Supports structured output, streaming, tools, conversation memory, middleware, queueing, broadcasting, and provider failover. Use when building, editing, updating, debugging, or testing any AI functionality, including agents, LLMs, chatbots, text generation, image generation, audio, transcription, embeddings, RAG, similarity search, vector stores, prompting, structured output, or any AI provider (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). +--- + +# Developing with the Laravel AI SDK + +The Laravel AI SDK (`laravel/ai`) is the official AI package for Laravel, providing a unified API for agents, images, audio, transcription, embeddings, reranking, vector stores, and file management across multiple AI providers. + +## Searching the Documentation + +This package is new. Always search the documentation before implementing any feature. Never guess at APIs — the documentation is the single source of truth. + +- Use broad, simple queries that match the documentation section headings below. +- Do not add package names to queries — package information is shared automatically. Use `test agent fake`, not `laravel ai test agent fake`. +- Run multiple queries at once — the most relevant results are returned first. + +### Documentation Sections + +Use these section headings as query terms for accurate results: + +- Introduction, Installation, Configuration, Provider Support +- Agents: Prompting, Conversation Context, Structured Output, Attachments, Streaming, Broadcasting, Queueing, Tools, Provider Tools, Middleware, Anonymous Agents, Agent Configuration +- Images +- Audio (TTS) +- Transcription (STT) +- Embeddings: Querying Embeddings, Caching Embeddings +- Reranking +- Files +- Vector Stores: Adding Files to Stores +- Failover +- Testing: Agents, Images, Audio, Transcriptions, Embeddings, Reranking, Files, Vector Stores +- Events + +## Decision Workflow + +Determine the right entry point before writing code: + +Text generation or chat? → Agent class with `Promptable` trait +Chat with conversation history? → Agent + `Conversational` interface (manual) or `RemembersConversations` trait (automatic) +Structured JSON output? → Agent + `HasStructuredOutput` interface +Image generation? → `Image::of()->generate()` +Audio synthesis? → `Audio::of()->generate()` +Transcription? → `Transcription::fromPath()->generate()` +Embeddings? → `Embeddings::for()->generate()` +Reranking? → `Reranking::of()->rerank()` +File storage? → `Document::fromPath()->put()` +Vector stores? → `Stores::create()` + +## Basic Usage Examples + +### Agents + +```php +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Promptable; + +class SalesCoach implements Agent +{ + use Promptable; + + public function instructions(): string + { + return 'You are a sales coach.'; + } +} + +// Prompting +$response = (new SalesCoach)->prompt('Analyze this transcript...'); +echo $response->text; + +// Streaming (returns SSE response from a route) +return (new SalesCoach)->stream('Analyze this transcript...'); + +// Queueing +(new SalesCoach)->queue('Analyze this transcript...') + ->then(fn ($response) => /* ... */); + +// Anonymous agents +use function Laravel\Ai\{agent}; + +$response = agent(instructions: 'You are a helpful assistant.')->prompt('Hello'); +``` + +### Conversation Context + +Manual conversation history via the `Conversational` interface: + +```php +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Contracts\Conversational; +use Laravel\Ai\Messages\Message; +use Laravel\Ai\Promptable; + +class SalesCoach implements Agent, Conversational +{ + use Promptable; + + public function __construct(public User $user) {} + + public function instructions(): string { return 'You are a sales coach.'; } + + public function messages(): iterable + { + return History::where('user_id', $this->user->id) + ->latest()->limit(50)->get()->reverse() + ->map(fn ($m) => new Message($m->role, $m->content)) + ->all(); + } +} +``` + +Automatic conversation persistence via the `RemembersConversations` trait: + +```php +use Laravel\Ai\Concerns\RemembersConversations; +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Contracts\Conversational; +use Laravel\Ai\Promptable; + +class SalesCoach implements Agent, Conversational +{ + use Promptable, RemembersConversations; + + public function instructions(): string { return 'You are a sales coach.'; } +} + +// Start a new conversation +$response = (new SalesCoach)->forUser($user)->prompt('Hello!'); +$conversationId = $response->conversationId; + +// Continue an existing conversation +$response = (new SalesCoach)->continue($conversationId, as: $user)->prompt('Tell me more.'); +``` + +### Structured Output + +```php +use Illuminate\Contracts\JsonSchema\JsonSchema; +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Contracts\HasStructuredOutput; +use Laravel\Ai\Promptable; + +class Reviewer implements Agent, HasStructuredOutput +{ + use Promptable; + + public function instructions(): string { return 'Review and score content.'; } + + public function schema(JsonSchema $schema): array + { + return [ + 'feedback' => $schema->string()->required(), + 'score' => $schema->integer()->min(1)->max(10)->required(), + ]; + } +} + +$response = (new Reviewer)->prompt('Review this...'); +echo $response['score']; // Access like an array +``` + +### Images + +```php +use Laravel\Ai\Image; + +$image = Image::of('A sunset over mountains') + ->landscape() + ->quality('high') + ->generate(); + +$path = $image->store(); // Store to default disk +``` + +### Audio + +```php +use Laravel\Ai\Audio; + +$audio = Audio::of('Hello from Laravel.') + ->female() + ->instructions('Speak warmly') + ->generate(); + +$path = $audio->store(); +``` + +### Transcription + +```php +use Laravel\Ai\Transcription; + +$transcript = Transcription::fromStorage('audio.mp3') + ->diarize() + ->generate(); + +echo (string) $transcript; +``` + +### Embeddings + +```php +use Laravel\Ai\Embeddings; +use Illuminate\Support\Str; + +$response = Embeddings::for(['Text one', 'Text two']) + ->dimensions(1536) + ->cache() + ->generate(); + +// Single string via Stringable +$embedding = Str::of('Napa Valley has great wine.')->toEmbeddings(); +``` + +### Reranking + +```php +use Laravel\Ai\Reranking; + +$response = Reranking::of(['Django is Python.', 'Laravel is PHP.', 'React is JS.']) + ->limit(5) + ->rerank('PHP frameworks'); + +$response->first()->document; // "Laravel is PHP." +``` + +### Files and Vector Stores + +```php +use Laravel\Ai\Files\Document; +use Laravel\Ai\Stores; + +// Store a file with the provider +$file = Document::fromPath('/path/to/doc.pdf')->put(); + +// Create a vector store and add files +$store = Stores::create('Knowledge Base'); +$store->add($file->id); +$store->add(Document::fromStorage('manual.pdf')); // Store + add in one step +``` + +## Agent Configuration + +### PHP Attributes + +```php +use Laravel\Ai\Attributes\{Provider, MaxSteps, MaxTokens, Temperature, Timeout}; + +#[Provider('anthropic')] +#[MaxSteps(10)] +#[MaxTokens(4096)] +#[Temperature(0.7)] +#[Timeout(120)] +class MyAgent implements Agent +{ + use Promptable; + // ... +} +``` + +The `#[UseCheapestModel]` and `#[UseSmartestModel]` attributes are also available for automatic model selection. + +### Tools + +Implement the `HasTools` interface and scaffold tools with `php artisan make:tool`: + +```php +use Laravel\Ai\Contracts\HasTools; + +class MyAgent implements Agent, HasTools +{ + use Promptable; + + public function tools(): iterable + { + return [new MyCustomTool]; + } +} +``` + +### Provider Tools + +```php +use Laravel\Ai\Providers\Tools\{WebSearch, WebFetch, FileSearch}; + +public function tools(): iterable +{ + return [ + (new WebSearch)->max(5)->allow(['laravel.com']), + new WebFetch, + new FileSearch(stores: ['store_id']), + ]; +} +``` + +### Conversation Memory + +```php +use Laravel\Ai\Concerns\RemembersConversations; +use Laravel\Ai\Contracts\Conversational; + +class ChatBot implements Agent, Conversational +{ + use Promptable, RemembersConversations; + // ... +} + +$response = (new ChatBot)->forUser($user)->prompt('Hello!'); +$response = (new ChatBot)->continue($conversationId, as: $user)->prompt('More...'); +``` + +### Failover + +```php +$response = (new MyAgent)->prompt('Hello', provider: ['openai', 'anthropic']); +``` + +## Testing and Faking + +Each capability supports `fake()` with assertions: + +```php +use App\Ai\Agents\SalesCoach; +use Laravel\Ai\{Image, Audio, Transcription, Embeddings, Reranking, Files, Stores}; + +// Agents +SalesCoach::fake(['Response 1', 'Response 2']); +SalesCoach::assertPrompted('query'); +SalesCoach::assertNotPrompted('query'); +SalesCoach::assertNeverPrompted(); +SalesCoach::fake()->preventStrayPrompts(); + +// Images +Image::fake(); +Image::assertGenerated(fn ($prompt) => $prompt->contains('sunset')); +Image::assertNothingGenerated(); + +// Audio +Audio::fake(); +Audio::assertGenerated(fn ($prompt) => $prompt->contains('Hello')); + +// Transcription +Transcription::fake(['Transcribed text.']); +Transcription::assertGenerated(fn ($prompt) => $prompt->isDiarized()); + +// Embeddings +Embeddings::fake(); +Embeddings::assertGenerated(fn ($prompt) => $prompt->contains('Laravel')); + +// Reranking +Reranking::fake(); +Reranking::assertReranked(fn ($prompt) => $prompt->contains('PHP')); + +// Files +Files::fake(); +Files::assertStored(fn ($file) => $file->mimeType() === 'text/plain'); + +// Stores +Stores::fake(); +Stores::assertCreated('Knowledge Base'); +$store = Stores::get('id'); +$store->assertAdded('file_id'); +``` + +## Key Patterns + +- Namespace: `Laravel\Ai\` +- Package: `composer require laravel/ai` +- Agent pattern: Implement the `Agent` interface and use the `Promptable` trait +- Optional interfaces: `HasTools`, `HasMiddleware`, `HasStructuredOutput`, `Conversational` +- Entry-point classes: `Image`, `Audio`, `Transcription`, `Embeddings`, `Reranking`, `Stores` +- Artisan commands: `php artisan make:agent`, `php artisan make:tool` +- Global helper: `agent()` for anonymous agents + +## Common Pitfalls + +### Wrong Namespace + +The namespace is `Laravel\Ai`, not `Illuminate\Ai` or `Laravel\AI`. + +```php +// Correct +use Laravel\Ai\Image; +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Promptable; + +// Wrong — these do not exist +use Illuminate\Ai\Image; +use Laravel\AI\Agent; +``` + +### Unsupported Provider Capability + +Calling a capability not supported by a provider throws a `LogicException`. Refer to the provider support table below. + +### Never Use Prism Directly + +Use agents and entry-point classes (`Image`, `Audio`, etc.) — not `Prism::text()` directly. The AI SDK wraps Prism internally. + +## Provider Support + +| Provider | Text | Image | Audio | STT | Embeddings | Reranking | Files | Stores | +| ---------- | ---- | ----- | ----- | --- | ---------- | --------- | ----- | ------ | +| OpenAI | Y | Y | Y | Y | Y | - | Y | Y | +| Anthropic | Y | - | - | - | - | - | Y | - | +| Gemini | Y | Y | - | - | Y | - | Y | Y | +| xAI | Y | Y | - | - | - | - | - | - | +| Groq | Y | - | - | - | - | - | - | - | +| OpenRouter | Y | - | - | - | - | - | - | - | +| ElevenLabs | - | - | Y | Y | - | - | - | - | +| Cohere | - | - | - | - | Y | Y | - | - | +| Jina | - | - | - | - | Y | Y | - | - | \ No newline at end of file diff --git a/_api_app/.env.example b/_api_app/.env.example index fcf24118d..e755a3387 100644 --- a/_api_app/.env.example +++ b/_api_app/.env.example @@ -6,3 +6,5 @@ APP_ID=[YOUR_APP_ID] API_PREFIX=_api SENTRY_DSN= SENTRY_FRONTEND_DSN= +AI_DEFAULT_PROVIDER=anthropic +ANTHROPIC_API_KEY= diff --git a/_api_app/.github/skills/ai-sdk-development/SKILL.md b/_api_app/.github/skills/ai-sdk-development/SKILL.md new file mode 100644 index 000000000..d74e342ec --- /dev/null +++ b/_api_app/.github/skills/ai-sdk-development/SKILL.md @@ -0,0 +1,413 @@ +--- +name: ai-sdk-development +description: Builds AI agents, generates text and chat responses, produces images, synthesizes audio, transcribes speech, generates vector embeddings, reranks documents, and manages files and vector stores using the Laravel AI SDK (laravel/ai). Supports structured output, streaming, tools, conversation memory, middleware, queueing, broadcasting, and provider failover. Use when building, editing, updating, debugging, or testing any AI functionality, including agents, LLMs, chatbots, text generation, image generation, audio, transcription, embeddings, RAG, similarity search, vector stores, prompting, structured output, or any AI provider (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). +--- + +# Developing with the Laravel AI SDK + +The Laravel AI SDK (`laravel/ai`) is the official AI package for Laravel, providing a unified API for agents, images, audio, transcription, embeddings, reranking, vector stores, and file management across multiple AI providers. + +## Searching the Documentation + +This package is new. Always search the documentation before implementing any feature. Never guess at APIs — the documentation is the single source of truth. + +- Use broad, simple queries that match the documentation section headings below. +- Do not add package names to queries — package information is shared automatically. Use `test agent fake`, not `laravel ai test agent fake`. +- Run multiple queries at once — the most relevant results are returned first. + +### Documentation Sections + +Use these section headings as query terms for accurate results: + +- Introduction, Installation, Configuration, Provider Support +- Agents: Prompting, Conversation Context, Structured Output, Attachments, Streaming, Broadcasting, Queueing, Tools, Provider Tools, Middleware, Anonymous Agents, Agent Configuration +- Images +- Audio (TTS) +- Transcription (STT) +- Embeddings: Querying Embeddings, Caching Embeddings +- Reranking +- Files +- Vector Stores: Adding Files to Stores +- Failover +- Testing: Agents, Images, Audio, Transcriptions, Embeddings, Reranking, Files, Vector Stores +- Events + +## Decision Workflow + +Determine the right entry point before writing code: + +Text generation or chat? → Agent class with `Promptable` trait +Chat with conversation history? → Agent + `Conversational` interface (manual) or `RemembersConversations` trait (automatic) +Structured JSON output? → Agent + `HasStructuredOutput` interface +Image generation? → `Image::of()->generate()` +Audio synthesis? → `Audio::of()->generate()` +Transcription? → `Transcription::fromPath()->generate()` +Embeddings? → `Embeddings::for()->generate()` +Reranking? → `Reranking::of()->rerank()` +File storage? → `Document::fromPath()->put()` +Vector stores? → `Stores::create()` + +## Basic Usage Examples + +### Agents + +```php +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Promptable; + +class SalesCoach implements Agent +{ + use Promptable; + + public function instructions(): string + { + return 'You are a sales coach.'; + } +} + +// Prompting +$response = (new SalesCoach)->prompt('Analyze this transcript...'); +echo $response->text; + +// Streaming (returns SSE response from a route) +return (new SalesCoach)->stream('Analyze this transcript...'); + +// Queueing +(new SalesCoach)->queue('Analyze this transcript...') + ->then(fn ($response) => /* ... */); + +// Anonymous agents +use function Laravel\Ai\{agent}; + +$response = agent(instructions: 'You are a helpful assistant.')->prompt('Hello'); +``` + +### Conversation Context + +Manual conversation history via the `Conversational` interface: + +```php +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Contracts\Conversational; +use Laravel\Ai\Messages\Message; +use Laravel\Ai\Promptable; + +class SalesCoach implements Agent, Conversational +{ + use Promptable; + + public function __construct(public User $user) {} + + public function instructions(): string { return 'You are a sales coach.'; } + + public function messages(): iterable + { + return History::where('user_id', $this->user->id) + ->latest()->limit(50)->get()->reverse() + ->map(fn ($m) => new Message($m->role, $m->content)) + ->all(); + } +} +``` + +Automatic conversation persistence via the `RemembersConversations` trait: + +```php +use Laravel\Ai\Concerns\RemembersConversations; +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Contracts\Conversational; +use Laravel\Ai\Promptable; + +class SalesCoach implements Agent, Conversational +{ + use Promptable, RemembersConversations; + + public function instructions(): string { return 'You are a sales coach.'; } +} + +// Start a new conversation +$response = (new SalesCoach)->forUser($user)->prompt('Hello!'); +$conversationId = $response->conversationId; + +// Continue an existing conversation +$response = (new SalesCoach)->continue($conversationId, as: $user)->prompt('Tell me more.'); +``` + +### Structured Output + +```php +use Illuminate\Contracts\JsonSchema\JsonSchema; +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Contracts\HasStructuredOutput; +use Laravel\Ai\Promptable; + +class Reviewer implements Agent, HasStructuredOutput +{ + use Promptable; + + public function instructions(): string { return 'Review and score content.'; } + + public function schema(JsonSchema $schema): array + { + return [ + 'feedback' => $schema->string()->required(), + 'score' => $schema->integer()->min(1)->max(10)->required(), + ]; + } +} + +$response = (new Reviewer)->prompt('Review this...'); +echo $response['score']; // Access like an array +``` + +### Images + +```php +use Laravel\Ai\Image; + +$image = Image::of('A sunset over mountains') + ->landscape() + ->quality('high') + ->generate(); + +$path = $image->store(); // Store to default disk +``` + +### Audio + +```php +use Laravel\Ai\Audio; + +$audio = Audio::of('Hello from Laravel.') + ->female() + ->instructions('Speak warmly') + ->generate(); + +$path = $audio->store(); +``` + +### Transcription + +```php +use Laravel\Ai\Transcription; + +$transcript = Transcription::fromStorage('audio.mp3') + ->diarize() + ->generate(); + +echo (string) $transcript; +``` + +### Embeddings + +```php +use Laravel\Ai\Embeddings; +use Illuminate\Support\Str; + +$response = Embeddings::for(['Text one', 'Text two']) + ->dimensions(1536) + ->cache() + ->generate(); + +// Single string via Stringable +$embedding = Str::of('Napa Valley has great wine.')->toEmbeddings(); +``` + +### Reranking + +```php +use Laravel\Ai\Reranking; + +$response = Reranking::of(['Django is Python.', 'Laravel is PHP.', 'React is JS.']) + ->limit(5) + ->rerank('PHP frameworks'); + +$response->first()->document; // "Laravel is PHP." +``` + +### Files and Vector Stores + +```php +use Laravel\Ai\Files\Document; +use Laravel\Ai\Stores; + +// Store a file with the provider +$file = Document::fromPath('/path/to/doc.pdf')->put(); + +// Create a vector store and add files +$store = Stores::create('Knowledge Base'); +$store->add($file->id); +$store->add(Document::fromStorage('manual.pdf')); // Store + add in one step +``` + +## Agent Configuration + +### PHP Attributes + +```php +use Laravel\Ai\Attributes\{Provider, MaxSteps, MaxTokens, Temperature, Timeout}; + +#[Provider('anthropic')] +#[MaxSteps(10)] +#[MaxTokens(4096)] +#[Temperature(0.7)] +#[Timeout(120)] +class MyAgent implements Agent +{ + use Promptable; + // ... +} +``` + +The `#[UseCheapestModel]` and `#[UseSmartestModel]` attributes are also available for automatic model selection. + +### Tools + +Implement the `HasTools` interface and scaffold tools with `php artisan make:tool`: + +```php +use Laravel\Ai\Contracts\HasTools; + +class MyAgent implements Agent, HasTools +{ + use Promptable; + + public function tools(): iterable + { + return [new MyCustomTool]; + } +} +``` + +### Provider Tools + +```php +use Laravel\Ai\Providers\Tools\{WebSearch, WebFetch, FileSearch}; + +public function tools(): iterable +{ + return [ + (new WebSearch)->max(5)->allow(['laravel.com']), + new WebFetch, + new FileSearch(stores: ['store_id']), + ]; +} +``` + +### Conversation Memory + +```php +use Laravel\Ai\Concerns\RemembersConversations; +use Laravel\Ai\Contracts\Conversational; + +class ChatBot implements Agent, Conversational +{ + use Promptable, RemembersConversations; + // ... +} + +$response = (new ChatBot)->forUser($user)->prompt('Hello!'); +$response = (new ChatBot)->continue($conversationId, as: $user)->prompt('More...'); +``` + +### Failover + +```php +$response = (new MyAgent)->prompt('Hello', provider: ['openai', 'anthropic']); +``` + +## Testing and Faking + +Each capability supports `fake()` with assertions: + +```php +use App\Ai\Agents\SalesCoach; +use Laravel\Ai\{Image, Audio, Transcription, Embeddings, Reranking, Files, Stores}; + +// Agents +SalesCoach::fake(['Response 1', 'Response 2']); +SalesCoach::assertPrompted('query'); +SalesCoach::assertNotPrompted('query'); +SalesCoach::assertNeverPrompted(); +SalesCoach::fake()->preventStrayPrompts(); + +// Images +Image::fake(); +Image::assertGenerated(fn ($prompt) => $prompt->contains('sunset')); +Image::assertNothingGenerated(); + +// Audio +Audio::fake(); +Audio::assertGenerated(fn ($prompt) => $prompt->contains('Hello')); + +// Transcription +Transcription::fake(['Transcribed text.']); +Transcription::assertGenerated(fn ($prompt) => $prompt->isDiarized()); + +// Embeddings +Embeddings::fake(); +Embeddings::assertGenerated(fn ($prompt) => $prompt->contains('Laravel')); + +// Reranking +Reranking::fake(); +Reranking::assertReranked(fn ($prompt) => $prompt->contains('PHP')); + +// Files +Files::fake(); +Files::assertStored(fn ($file) => $file->mimeType() === 'text/plain'); + +// Stores +Stores::fake(); +Stores::assertCreated('Knowledge Base'); +$store = Stores::get('id'); +$store->assertAdded('file_id'); +``` + +## Key Patterns + +- Namespace: `Laravel\Ai\` +- Package: `composer require laravel/ai` +- Agent pattern: Implement the `Agent` interface and use the `Promptable` trait +- Optional interfaces: `HasTools`, `HasMiddleware`, `HasStructuredOutput`, `Conversational` +- Entry-point classes: `Image`, `Audio`, `Transcription`, `Embeddings`, `Reranking`, `Stores` +- Artisan commands: `php artisan make:agent`, `php artisan make:tool` +- Global helper: `agent()` for anonymous agents + +## Common Pitfalls + +### Wrong Namespace + +The namespace is `Laravel\Ai`, not `Illuminate\Ai` or `Laravel\AI`. + +```php +// Correct +use Laravel\Ai\Image; +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Promptable; + +// Wrong — these do not exist +use Illuminate\Ai\Image; +use Laravel\AI\Agent; +``` + +### Unsupported Provider Capability + +Calling a capability not supported by a provider throws a `LogicException`. Refer to the provider support table below. + +### Never Use Prism Directly + +Use agents and entry-point classes (`Image`, `Audio`, etc.) — not `Prism::text()` directly. The AI SDK wraps Prism internally. + +## Provider Support + +| Provider | Text | Image | Audio | STT | Embeddings | Reranking | Files | Stores | +| ---------- | ---- | ----- | ----- | --- | ---------- | --------- | ----- | ------ | +| OpenAI | Y | Y | Y | Y | Y | - | Y | Y | +| Anthropic | Y | - | - | - | - | - | Y | - | +| Gemini | Y | Y | - | - | Y | - | Y | Y | +| xAI | Y | Y | - | - | - | - | - | - | +| Groq | Y | - | - | - | - | - | - | - | +| OpenRouter | Y | - | - | - | - | - | - | - | +| ElevenLabs | - | - | Y | Y | - | - | - | - | +| Cohere | - | - | - | - | Y | Y | - | - | +| Jina | - | - | - | - | Y | Y | - | - | \ No newline at end of file diff --git a/_api_app/AGENTS.md b/_api_app/AGENTS.md index 757ae9646..23eb4ca32 100644 --- a/_api_app/AGENTS.md +++ b/_api_app/AGENTS.md @@ -9,7 +9,8 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. -- php - 8.2.30 +- php - 8.3.29 +- laravel/ai (AI) - v0 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 - laravel/sanctum (SANCTUM) - v4 @@ -25,6 +26,7 @@ This application is a Laravel application and its main Laravel ecosystems packag This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. - `pest-testing` — Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works. +- `ai-sdk-development` — Builds AI agents, generates text and chat responses, produces images, synthesizes audio, transcribes speech, generates vector embeddings, reranks documents, and manages files and vector stores using the Laravel AI SDK (laravel/ai). Supports structured output, streaming, tools, conversation memory, middleware, queueing, broadcasting, and provider failover. Use when building, editing, updating, debugging, or testing any AI functionality, including agents, LLMs, chatbots, text generation, image generation, audio, transcription, embeddings, RAG, similarity search, vector stores, prompting, structured output, or any AI provider (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). ## Conventions @@ -59,19 +61,23 @@ This project has domain-specific skills available. You MUST activate the relevan - Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. -## Artisan +## Artisan Commands -- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters. +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`, `php artisan tinker --execute "..."`). +- Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. ## URLs - Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port. -## Tinker / Debugging +## Debugging -- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. - Use the `database-query` tool when you only need to read from the database. - Use the `database-schema` tool to inspect table structure before writing migrations or models. +- To execute PHP code for debugging, run `php artisan tinker --execute "your code here"` directly. +- To read configuration values, read the config files directly or run `php artisan config:show [key]`. +- To inspect routes, run `php artisan route:list` directly. +- To check environment variables, read the `.env` file directly. ## Reading Browser Logs With the `browser-logs` Tool @@ -141,7 +147,7 @@ protected function isAccessible(User $user, ?string $path = null): bool # Do Things the Laravel Way -- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`. - If you're creating a generic PHP class, use `php artisan make:class`. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. @@ -155,7 +161,7 @@ protected function isAccessible(User $user, ?string $path = null): bool ### Model Creation -- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options. ### APIs & Eloquent Resources @@ -221,8 +227,8 @@ protected function isAccessible(User $user, ?string $path = null): bool # Laravel Pint Code Formatter -- If you have modified any PHP files, you must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. -- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. === pest/core rules === @@ -234,4 +240,11 @@ protected function isAccessible(User $user, ?string $path = null): bool - CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. - IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. +=== laravel/ai rules === + +## Laravel AI SDK + +- This application uses the Laravel AI SDK (`laravel/ai`) for all AI functionality. +- Activate the `developing-with-ai-sdk` skill when building, editing, updating, debugging, or testing AI agents, text generation, chat, streaming, structured output, tools, image generation, audio, transcription, embeddings, reranking, vector stores, files, conversation memory, or any AI provider integration (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). + diff --git a/_api_app/CLAUDE.md b/_api_app/CLAUDE.md index 757ae9646..23eb4ca32 100644 --- a/_api_app/CLAUDE.md +++ b/_api_app/CLAUDE.md @@ -9,7 +9,8 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. -- php - 8.2.30 +- php - 8.3.29 +- laravel/ai (AI) - v0 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 - laravel/sanctum (SANCTUM) - v4 @@ -25,6 +26,7 @@ This application is a Laravel application and its main Laravel ecosystems packag This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. - `pest-testing` — Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works. +- `ai-sdk-development` — Builds AI agents, generates text and chat responses, produces images, synthesizes audio, transcribes speech, generates vector embeddings, reranks documents, and manages files and vector stores using the Laravel AI SDK (laravel/ai). Supports structured output, streaming, tools, conversation memory, middleware, queueing, broadcasting, and provider failover. Use when building, editing, updating, debugging, or testing any AI functionality, including agents, LLMs, chatbots, text generation, image generation, audio, transcription, embeddings, RAG, similarity search, vector stores, prompting, structured output, or any AI provider (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). ## Conventions @@ -59,19 +61,23 @@ This project has domain-specific skills available. You MUST activate the relevan - Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. -## Artisan +## Artisan Commands -- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters. +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`, `php artisan tinker --execute "..."`). +- Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. ## URLs - Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port. -## Tinker / Debugging +## Debugging -- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. - Use the `database-query` tool when you only need to read from the database. - Use the `database-schema` tool to inspect table structure before writing migrations or models. +- To execute PHP code for debugging, run `php artisan tinker --execute "your code here"` directly. +- To read configuration values, read the config files directly or run `php artisan config:show [key]`. +- To inspect routes, run `php artisan route:list` directly. +- To check environment variables, read the `.env` file directly. ## Reading Browser Logs With the `browser-logs` Tool @@ -141,7 +147,7 @@ protected function isAccessible(User $user, ?string $path = null): bool # Do Things the Laravel Way -- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`. - If you're creating a generic PHP class, use `php artisan make:class`. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. @@ -155,7 +161,7 @@ protected function isAccessible(User $user, ?string $path = null): bool ### Model Creation -- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options. ### APIs & Eloquent Resources @@ -221,8 +227,8 @@ protected function isAccessible(User $user, ?string $path = null): bool # Laravel Pint Code Formatter -- If you have modified any PHP files, you must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. -- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. === pest/core rules === @@ -234,4 +240,11 @@ protected function isAccessible(User $user, ?string $path = null): bool - CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. - IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. +=== laravel/ai rules === + +## Laravel AI SDK + +- This application uses the Laravel AI SDK (`laravel/ai`) for all AI functionality. +- Activate the `developing-with-ai-sdk` skill when building, editing, updating, debugging, or testing AI agents, text generation, chat, streaming, structured output, tools, image generation, audio, transcription, embeddings, reranking, vector stores, files, conversation memory, or any AI provider integration (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). + diff --git a/_api_app/app/Http/Controllers/StateController.php b/_api_app/app/Http/Controllers/StateController.php index fb063b167..f3ebf304e 100644 --- a/_api_app/app/Http/Controllers/StateController.php +++ b/_api_app/app/Http/Controllers/StateController.php @@ -14,7 +14,9 @@ use App\Sites\TemplateSettings\SiteTemplateSettingsDataService; use App\Sites\ThemesDataService; use App\User\UserModel; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Route; class StateController extends Controller { @@ -45,6 +47,10 @@ public function get($site = '') 'entryGallery' => route('entry_gallery'), 'entryGalleryUpload' => route('entry_gallery_upload'), ]; + + if (Route::has('ai_chat')) { + $state['urls']['aiChat'] = route('ai_chat'); + } $state['sites'] = $sitesDataService->getState(); $state['site_settings'] = []; $state['site_sections'] = []; @@ -126,10 +132,8 @@ public function getSentryDSN() /** * Returns translated settings for site localization: templates and settings config - * - * @return json */ - public function getLocaleSettings(Request $request) + public function getLocaleSettings(Request $request): JsonResponse { $lang = $request->query('language'); diff --git a/_api_app/app/Http/Middleware/Authenticate.php b/_api_app/app/Http/Middleware/Authenticate.php index 6cbec35d6..ca43c51f9 100644 --- a/_api_app/app/Http/Middleware/Authenticate.php +++ b/_api_app/app/Http/Middleware/Authenticate.php @@ -5,15 +5,9 @@ use Closure; use Illuminate\Contracts\Auth\Factory as Auth; use Illuminate\Http\Request; -use Symfony\Component\HttpFoundation\Response; class Authenticate { - /** - * The authentication guard factory instance. - * - * @var \Illuminate\Contracts\Auth\Factory - */ protected $auth; /** @@ -26,16 +20,6 @@ public function __construct(Auth $auth) $this->auth = $auth; } - /** - * Handle an incoming request. - * - * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next - */ - // public function handle(Request $request, Closure $next): Response - // { - // return $next($request); - // } - /** * Handle an incoming request. * diff --git a/_api_app/app/Http/Middleware/SetupMiddleware.php b/_api_app/app/Http/Middleware/SetupMiddleware.php index b6ac6dc89..2f4547dd4 100644 --- a/_api_app/app/Http/Middleware/SetupMiddleware.php +++ b/_api_app/app/Http/Middleware/SetupMiddleware.php @@ -12,7 +12,7 @@ class SetupMiddleware /** * Handle an incoming request. * - * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + * @param Closure(Request): (Response) $next */ public function handle(Request $request, Closure $next): Response { diff --git a/_api_app/app/User/UserModel.php b/_api_app/app/User/UserModel.php index 6e7205a59..d9797c59c 100644 --- a/_api_app/app/User/UserModel.php +++ b/_api_app/app/User/UserModel.php @@ -93,6 +93,7 @@ private function getFeatures() if (! $this->profile_url || $plan) { $features[] = 'custom_javascript'; + $features[] = 'ai_assistant'; } if ($is_trial || $plan > 1) { diff --git a/_api_app/boost.json b/_api_app/boost.json index 1f17a8721..1a418e377 100644 --- a/_api_app/boost.json +++ b/_api_app/boost.json @@ -7,11 +7,15 @@ "opencode" ], "guidelines": true, - "herd_mcp": false, + "herd_mcp": true, "mcp": true, "nightwatch_mcp": false, + "packages": [ + "laravel/ai" + ], "sail": false, "skills": [ - "pest-testing" + "pest-testing", + "ai-sdk-development" ] } diff --git a/_api_app/bootstrap/providers.php b/_api_app/bootstrap/providers.php index 6c4cd2685..f271445ed 100644 --- a/_api_app/bootstrap/providers.php +++ b/_api_app/bootstrap/providers.php @@ -1,6 +1,9 @@ env('AI_DEFAULT_PROVIDER', 'anthropic'), + 'default_for_images' => 'gemini', + 'default_for_audio' => 'openai', + 'default_for_transcription' => 'openai', + 'default_for_embeddings' => 'openai', + 'default_for_reranking' => 'cohere', + + /* + |-------------------------------------------------------------------------- + | Caching + |-------------------------------------------------------------------------- + | + | Below you may configure caching strategies for AI related operations + | such as embedding generation. You are free to adjust these values + | based on your application's available caching stores and needs. + | + */ + + 'caching' => [ + 'embeddings' => [ + 'cache' => false, + 'store' => env('CACHE_STORE', 'database'), + ], + ], + + /* + |-------------------------------------------------------------------------- + | AI Providers + |-------------------------------------------------------------------------- + | + | Below are each of your AI providers defined for this application. Each + | represents an AI provider and API key combination which can be used + | to perform tasks like text, image, and audio creation via agents. + | + */ + + 'providers' => [ + 'anthropic' => [ + 'driver' => 'anthropic', + 'key' => env('ANTHROPIC_API_KEY'), + ], + + 'azure' => [ + 'driver' => 'azure', + 'key' => env('AZURE_OPENAI_API_KEY'), + 'url' => env('AZURE_OPENAI_URL'), + 'api_version' => env('AZURE_OPENAI_API_VERSION', '2024-10-21'), + 'deployment' => env('AZURE_OPENAI_DEPLOYMENT', 'gpt-4o'), + 'embedding_deployment' => env('AZURE_OPENAI_EMBEDDING_DEPLOYMENT', 'text-embedding-3-small'), + ], + + 'cohere' => [ + 'driver' => 'cohere', + 'key' => env('COHERE_API_KEY'), + ], + + 'deepseek' => [ + 'driver' => 'deepseek', + 'key' => env('DEEPSEEK_API_KEY'), + ], + + 'eleven' => [ + 'driver' => 'eleven', + 'key' => env('ELEVENLABS_API_KEY'), + ], + + 'gemini' => [ + 'driver' => 'gemini', + 'key' => env('GEMINI_API_KEY'), + ], + + 'groq' => [ + 'driver' => 'groq', + 'key' => env('GROQ_API_KEY'), + ], + + 'jina' => [ + 'driver' => 'jina', + 'key' => env('JINA_API_KEY'), + ], + + 'mistral' => [ + 'driver' => 'mistral', + 'key' => env('MISTRAL_API_KEY'), + ], + + 'ollama' => [ + 'driver' => 'ollama', + 'key' => env('OLLAMA_API_KEY', ''), + 'url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'), + ], + + 'openai' => [ + 'driver' => 'openai', + 'key' => env('OPENAI_API_KEY'), + ], + + 'openrouter' => [ + 'driver' => 'openrouter', + 'key' => env('OPENROUTER_API_KEY'), + ], + + 'voyageai' => [ + 'driver' => 'voyageai', + 'key' => env('VOYAGEAI_API_KEY'), + ], + + 'xai' => [ + 'driver' => 'xai', + 'key' => env('XAI_API_KEY'), + ], + ], + +]; diff --git a/_api_app/config/auth.php b/_api_app/config/auth.php index 393071e20..ee9733fea 100644 --- a/_api_app/config/auth.php +++ b/_api_app/config/auth.php @@ -1,5 +1,7 @@ [ 'users' => [ 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', App\Models\User::class), + 'model' => env('AUTH_MODEL', User::class), ], // 'users' => [ diff --git a/_api_app/config/sanctum.php b/_api_app/config/sanctum.php index 764a82fac..b6607039b 100644 --- a/_api_app/config/sanctum.php +++ b/_api_app/config/sanctum.php @@ -1,5 +1,8 @@ [ - 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, - 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, - 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, ], ]; diff --git a/_api_app/config/twigbridge.php b/_api_app/config/twigbridge.php index 68957f823..eb66cb4b1 100644 --- a/_api_app/config/twigbridge.php +++ b/_api_app/config/twigbridge.php @@ -1,5 +1,7 @@ [ - \Illuminate\Contracts\Support\Htmlable::class => ['html'], + Htmlable::class => ['html'], ], /* diff --git a/_api_app/database/factories/UserFactory.php b/_api_app/database/factories/UserFactory.php index fb800423e..b9ced1082 100644 --- a/_api_app/database/factories/UserFactory.php +++ b/_api_app/database/factories/UserFactory.php @@ -2,13 +2,14 @@ namespace Database\Factories; +use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; // use Illuminate\Support\Facades\Hash; // use Illuminate\Support\Str; /** - * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User> + * @extends Factory */ class UserFactory extends Factory { diff --git a/_api_app/opencode.json b/_api_app/opencode.json index 53e16f3d5..c43fb5c84 100644 --- a/_api_app/opencode.json +++ b/_api_app/opencode.json @@ -9,6 +9,17 @@ "artisan", "boost:mcp" ] + }, + "herd": { + "type": "local", + "enabled": true, + "command": [ + "php", + "/Applications/Herd.app/Contents/Resources/herd-mcp.phar" + ], + "environment": { + "SITE_PATH": "/Users/uldis/projects/berta/berta/_api_app" + } } } } \ No newline at end of file diff --git a/_api_app/tests/Feature/AiChatControllerTest.php b/_api_app/tests/Feature/AiChatControllerTest.php new file mode 100644 index 000000000..f8d2cea32 --- /dev/null +++ b/_api_app/tests/Feature/AiChatControllerTest.php @@ -0,0 +1,163 @@ + 'Make the background blue', + 'site' => '', + 'template' => 'default', + ])->assertStatus(401); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('parses structured json response from anthropic', function () { + AssistantAgent::fake([ + '{"reply": "Changed background to blue.", "design_changes": [{"group": "background", "setting": "backgroundColor", "value": "#0000ff"}], "settings_changes": []}', + ]); + + $agent = new AssistantAgent('system prompt'); + $result = $agent->chat([['role' => 'user', 'content' => 'make background blue']]); + + expect($result['reply'])->toBe('Changed background to blue.') + ->and($result['design_changes'])->toHaveCount(1) + ->and($result['design_changes'][0]['group'])->toBe('background') + ->and($result['design_changes'][0]['setting'])->toBe('backgroundColor') + ->and($result['design_changes'][0]['value'])->toBe('#0000ff') + ->and($result['settings_changes'])->toBeEmpty(); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('returns empty changes when ai response has no json', function () { + AssistantAgent::fake([ + 'I cannot help with that.', + ]); + + $agent = new AssistantAgent('system prompt'); + $result = $agent->chat([['role' => 'user', 'content' => 'hello']]); + + expect($result['reply'])->toBe('I cannot help with that.') + ->and($result['design_changes'])->toBeEmpty() + ->and($result['settings_changes'])->toBeEmpty(); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('returns empty changes when json is embedded in prose', function () { + AssistantAgent::fake([ + 'Sure! Here is my response: {"reply": "Done!", "design_changes": [], "settings_changes": []} — let me know if you need more.', + ]); + + $agent = new AssistantAgent('system prompt'); + $result = $agent->chat([['role' => 'user', 'content' => 'reset']]); + + expect($result['reply'])->toBe('Done!') + ->and($result['design_changes'])->toBeEmpty() + ->and($result['settings_changes'])->toBeEmpty(); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('parses site settings changes from ai response', function () { + AssistantAgent::fake([ + '{"reply": "Updated the page title.", "design_changes": [], "settings_changes": [{"group": "texts", "setting": "pageTitle", "value": "My Site"}]}', + ]); + + $agent = new AssistantAgent('system prompt'); + $result = $agent->chat([['role' => 'user', 'content' => 'set the page title to My Site']]); + + expect($result['reply'])->toBe('Updated the page title.') + ->and($result['design_changes'])->toBeEmpty() + ->and($result['settings_changes'])->toHaveCount(1) + ->and($result['settings_changes'][0]['group'])->toBe('texts') + ->and($result['settings_changes'][0]['setting'])->toBe('pageTitle') + ->and($result['settings_changes'][0]['value'])->toBe('My Site'); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('parses is_undo from ai response', function () { + AssistantAgent::fake([ + '{"reply": "Reverted font size.", "is_undo": true, "design_changes": [{"group": "bodyText", "setting": "fontSize", "value": "12px"}], "settings_changes": []}', + ]); + + $agent = new AssistantAgent('system prompt'); + $result = $agent->chat([['role' => 'user', 'content' => 'undo']]); + + expect($result['is_undo'])->toBeTrue() + ->and($result['reply'])->toBe('Reverted font size.') + ->and($result['design_changes'])->toHaveCount(1) + ->and($result['design_changes'][0]['value'])->toBe('12px'); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('enriches changes with previous_value', function () { + $controller = new AiChatController; + $method = new ReflectionMethod($controller, 'enrichChangesWithPreviousValues'); + + $changes = [ + ['group' => 'bodyText', 'setting' => 'fontSize', 'value' => '16px'], + ['group' => 'bodyText', 'setting' => 'fontFamily', 'value' => 'Arial'], + ]; + $currentSettings = [ + 'bodyText' => ['fontSize' => '12px'], + ]; + + $result = $method->invoke($controller, $changes, $currentSettings); + + expect($result[0]['previous_value'])->toBe('12px') + ->and($result[1]['previous_value'])->toBeNull(); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('includes change history in system prompt', function () { + $controller = new AiChatController; + $method = new ReflectionMethod($controller, 'buildChangeHistorySection'); + + $changeHistory = [ + [ + 'user_message' => 'make the font bigger', + 'design_changes' => [ + ['group' => 'bodyText', 'setting' => 'fontSize', 'value' => '16px', 'previous_value' => '12px'], + ], + 'settings_changes' => [], + ], + [ + 'user_message' => 'make background dark', + 'design_changes' => [ + ['group' => 'background', 'setting' => 'backgroundColor', 'value' => '#000000', 'previous_value' => '#ffffff'], + ], + 'settings_changes' => [], + ], + ]; + + $result = $method->invoke($controller, $changeHistory); + + expect($result) + ->toContain('Change History') + ->toContain('make the font bigger') + ->toContain('bodyText > fontSize') + ->toContain('"12px" → "16px"') + ->toContain('make background dark') + ->toContain('background > backgroundColor') + ->toContain('"#ffffff" → "#000000"'); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('omits change history section when history is empty', function () { + $controller = new AiChatController; + $method = new ReflectionMethod($controller, 'buildChangeHistorySection'); + + $result = $method->invoke($controller, []); + + expect($result)->toBe(''); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('includes help articles in system prompt', function () { + $controller = new AiChatController; + $method = new ReflectionMethod($controller, 'buildHelpArticlesSection'); + + $result = $method->invoke($controller); + + expect($result) + ->toContain('Help Articles') + ->toContain('How to Add a Video') + ->toContain('https://support.berta.me/en/frequently-asked-questions/how-to-add-a-video') + ->toContain('support.berta.me') + ->toContain('Domains') + ->toContain('SSL Certificates and HTTPS'); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/_api_app/tests/Pest.php b/_api_app/tests/Pest.php index b239048cc..fbc6d9aee 100644 --- a/_api_app/tests/Pest.php +++ b/_api_app/tests/Pest.php @@ -1,5 +1,7 @@ extend(Tests\TestCase::class)->in('Feature'); +pest()->extend(TestCase::class)->in('Feature'); /* |-------------------------------------------------------------------------- diff --git a/editor/package-lock.json b/editor/package-lock.json index 133dc84dc..cd44c41d0 100644 --- a/editor/package-lock.json +++ b/editor/package-lock.json @@ -21,6 +21,7 @@ "@ngxs/store": "~20.1.0", "@sentry/angular": "^10.18.0", "lodash": "^4.17.21", + "marked": "^17.0.4", "ng-sortgrid": "^20.0.0", "ngx-color-picker": "^20.1.1", "ngx-image-cropper": "^9.1.5", @@ -7814,6 +7815,18 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/marked": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz", + "integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/editor/package.json b/editor/package.json index 4c1a08571..8f333fccb 100644 --- a/editor/package.json +++ b/editor/package.json @@ -28,6 +28,7 @@ "@ngxs/store": "~20.1.0", "@sentry/angular": "^10.18.0", "lodash": "^4.17.21", + "marked": "^17.0.4", "ng-sortgrid": "^20.0.0", "ngx-color-picker": "^20.1.1", "ngx-image-cropper": "^9.1.5", diff --git a/editor/src/app/ai-assistant/ai-assistant.actions.ts b/editor/src/app/ai-assistant/ai-assistant.actions.ts new file mode 100644 index 000000000..c97a42344 --- /dev/null +++ b/editor/src/app/ai-assistant/ai-assistant.actions.ts @@ -0,0 +1,22 @@ +export class ToggleAiAssistantAction { + static readonly type = 'AI_ASSISTANT:TOGGLE'; +} + +export class SendAiMessageAction { + static readonly type = 'AI_ASSISTANT:SEND_MESSAGE'; + constructor(public message: string) {} +} + +export class AiMessageReceivedAction { + static readonly type = 'AI_ASSISTANT:MESSAGE_RECEIVED'; + constructor( + public reply: string, + public designChanges: { group: string; setting: string; value: string; previous_value?: string | null }[], + public settingsChanges: { group: string; setting: string; value: string; previous_value?: string | null }[], + public isUndo: boolean = false, + ) {} +} + +export class ClearAiChatAction { + static readonly type = 'AI_ASSISTANT:CLEAR'; +} diff --git a/editor/src/app/ai-assistant/ai-assistant.component.ts b/editor/src/app/ai-assistant/ai-assistant.component.ts new file mode 100644 index 000000000..86a1405f8 --- /dev/null +++ b/editor/src/app/ai-assistant/ai-assistant.component.ts @@ -0,0 +1,359 @@ +import { + Component, + ViewChild, + ElementRef, + AfterViewChecked, + OnDestroy, +} from '@angular/core'; +import { Observable, Subscription } from 'rxjs'; +import { Store } from '@ngxs/store'; + +import { AiAssistantState, AiMessage } from './ai-assistant.state'; +import { + SendAiMessageAction, + ClearAiChatAction, + ToggleAiAssistantAction, +} from './ai-assistant.actions'; + +@Component({ + selector: 'berta-ai-assistant', + template: ` + @if (isOpen$ | async) { +
+
+ AI Assistant +
+ Clear + +
+
+
+ @if ((messages$ | async)?.length === 0) { +

+ Ask me to change design or site settings.
+ e.g. "Make the background dark blue" or "Set the page title to My + Site" +

+ } + @for (msg of messages$ | async; track $index) { +
+ @if (msg.role === 'assistant') { + + } @else { + {{ msg.content }} + } +
+ } + @if (isLoading$ | async) { +
+ +
+ } +
+
+ + +
+
+ } + `, + styles: [ + ` + .ai-panel { + position: fixed; + top: 4.63em; + right: 0; + width: 320px; + bottom: 8.5em; + background: #fff; + border-left: 1px solid #ddd; + display: flex; + flex-direction: column; + z-index: 2; + box-shadow: -2px 2px 8px rgba(0, 0, 0, 0.1); + font-family: inherit; + font-size: 13px; + } + + .ai-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75em 1em; + border-bottom: 1px solid #ddd; + font-weight: bold; + flex-shrink: 0; + } + + .ai-panel-actions { + display: flex; + align-items: center; + gap: 0.75em; + } + + .ai-panel-actions a { + font-size: 12px; + color: #777; + text-decoration: none; + } + + .ai-panel-actions a:hover { + color: #333; + } + + .close-btn { + background: none; + border: none; + cursor: pointer; + font-size: 18px; + line-height: 1; + color: #777; + padding: 0; + } + + .close-btn:hover { + color: #333; + } + + .ai-messages { + flex-grow: 1; + overflow-y: auto; + padding: 1em; + display: flex; + flex-direction: column; + gap: 0.5em; + } + + .ai-empty { + color: #aaa; + font-size: 12px; + text-align: center; + margin: auto; + line-height: 1.6; + } + + .ai-message { + max-width: 85%; + padding: 0.5em 0.75em; + border-radius: 8px; + line-height: 1.5; + word-break: break-word; + } + + .ai-message--user { + align-self: flex-end; + background: #333; + color: #fff; + } + + .ai-message--assistant { + align-self: flex-start; + background: #f0f0f0; + color: #333; + } + + .ai-message--loading { + display: flex; + gap: 4px; + align-items: center; + padding: 0.6em 0.75em; + } + + .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #999; + animation: bounce 1.2s infinite ease-in-out; + } + + .dot:nth-child(2) { + animation-delay: 0.2s; + } + + .dot:nth-child(3) { + animation-delay: 0.4s; + } + + @keyframes bounce { + 0%, + 60%, + 100% { + transform: translateY(0); + } + 30% { + transform: translateY(-4px); + } + } + + .ai-input-area { + padding: 0.75em; + border-top: 1px solid #ddd; + display: flex; + flex-direction: column; + gap: 0.5em; + flex-shrink: 0; + } + + .ai-input-area textarea { + flex-grow: 1; + resize: none; + border: 1px solid #ddd; + border-radius: 4px; + padding: 0.5em; + font-family: inherit; + font-size: 12px; + line-height: 1.4; + } + + .ai-input-area textarea:focus { + outline: none; + border-color: #999; + } + + .ai-input-area button { + align-self: flex-start; + padding: 0.4em 0.8em; + background: #333; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + white-space: nowrap; + } + + .ai-input-area button:disabled { + opacity: 0.4; + cursor: default; + } + + .ai-input-area button:hover:not(:disabled) { + background: #555; + } + + .ai-message--assistant p { + margin: 0 0 0.4em; + } + + .ai-message--assistant p:last-child { + margin-bottom: 0; + } + + .ai-message--assistant ul, + .ai-message--assistant ol { + margin: 0.3em 0; + padding-left: 1.4em; + } + + .ai-message--assistant code { + background: #e8e8e8; + border-radius: 3px; + padding: 0.1em 0.3em; + font-size: 11px; + } + + .ai-message--assistant strong { + font-weight: 600; + } + `, + ], + standalone: false, +}) +export class AiAssistantComponent implements AfterViewChecked, OnDestroy { + isOpen$: Observable; + messages$: Observable; + isLoading$: Observable; + inputText = ''; + + @ViewChild('messagesContainer') private messagesContainer: ElementRef; + @ViewChild('inputEl') private inputEl: ElementRef; + + private shouldFocus = false; + private shouldScroll = false; + private messageCount = 0; + private subs: Subscription[] = []; + + constructor(private store: Store) { + this.isOpen$ = this.store.select(AiAssistantState.isOpen); + this.messages$ = this.store.select(AiAssistantState.messages); + this.isLoading$ = this.store.select(AiAssistantState.isLoading); + this.subs.push( + this.isOpen$.subscribe((open) => { + if (open) this.shouldFocus = true; + }), + this.messages$.subscribe((msgs) => { + if (msgs.length > this.messageCount) this.shouldScroll = true; + this.messageCount = msgs.length; + }), + this.isLoading$.subscribe((loading) => { + if (loading) this.shouldScroll = true; + }), + ); + } + + ngAfterViewChecked() { + if (this.shouldScroll) { + this.scrollToBottom(); + this.shouldScroll = false; + } + if (this.shouldFocus && this.inputEl) { + this.inputEl.nativeElement.focus(); + this.shouldFocus = false; + } + } + + ngOnDestroy() { + this.subs.forEach((s) => s.unsubscribe()); + } + + send() { + const text = this.inputText.trim(); + if (!text) { + return; + } + this.inputText = ''; + this.store.dispatch(new SendAiMessageAction(text)); + } + + onEnter(event: KeyboardEvent) { + if (!event.shiftKey) { + event.preventDefault(); + this.send(); + } + } + + clearChat(event: Event) { + event.preventDefault(); + this.store.dispatch(new ClearAiChatAction()); + } + + close() { + this.store.dispatch(new ToggleAiAssistantAction()); + } + + private scrollToBottom() { + if (this.messagesContainer) { + const el = this.messagesContainer.nativeElement; + el.scrollTop = el.scrollHeight; + } + } +} diff --git a/editor/src/app/ai-assistant/ai-assistant.module.ts b/editor/src/app/ai-assistant/ai-assistant.module.ts new file mode 100644 index 000000000..9ee99f944 --- /dev/null +++ b/editor/src/app/ai-assistant/ai-assistant.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +import { PipesModule } from '../pipes/pipes.module'; +import { AiAssistantComponent } from './ai-assistant.component'; + +@NgModule({ + imports: [CommonModule, FormsModule, PipesModule], + declarations: [AiAssistantComponent], + exports: [AiAssistantComponent], +}) +export class AiAssistantModule {} diff --git a/editor/src/app/ai-assistant/ai-assistant.service.ts b/editor/src/app/ai-assistant/ai-assistant.service.ts new file mode 100644 index 000000000..06a586c86 --- /dev/null +++ b/editor/src/app/ai-assistant/ai-assistant.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { AppStateService } from '../app-state/app-state.service'; + +export interface AiChangeItem { + group: string; + setting: string; + value: string; + previous_value?: string | null; +} + +export interface AiChatResponse { + reply: string; + is_undo: boolean; + design_changes: AiChangeItem[]; + settings_changes: AiChangeItem[]; +} + +@Injectable({ + providedIn: 'root', +}) +export class AiAssistantService { + constructor(private appStateService: AppStateService) {} + + chat( + message: string, + history: { role: string; content: string }[], + site: string, + template: string, + changeHistory: { user_message: string; design_changes: AiChangeItem[]; settings_changes: AiChangeItem[] }[] = [], + ): Observable { + return this.appStateService + .sync('aiChat', { message, history, site, template, change_history: changeHistory }, 'POST') + .pipe(map((response: any) => response.data as AiChatResponse)); + } +} diff --git a/editor/src/app/ai-assistant/ai-assistant.state.ts b/editor/src/app/ai-assistant/ai-assistant.state.ts new file mode 100644 index 000000000..824bbd96d --- /dev/null +++ b/editor/src/app/ai-assistant/ai-assistant.state.ts @@ -0,0 +1,196 @@ +import { Injectable } from '@angular/core'; +import { State, Action, StateContext, Selector } from '@ngxs/store'; +import { tap, catchError } from 'rxjs/operators'; +import { EMPTY } from 'rxjs'; + +import { Store } from '@ngxs/store'; +import { AppState } from '../app-state/app.state'; +import { SiteSettingsState } from '../sites/settings/site-settings.state'; +import { UpdateSiteTemplateSettingsAction } from '../sites/template-settings/site-template-settings.actions'; +import { UpdateSiteSettingsAction } from '../sites/settings/site-settings.actions'; +import { AiAssistantService } from './ai-assistant.service'; +import { + ToggleAiAssistantAction, + SendAiMessageAction, + AiMessageReceivedAction, + ClearAiChatAction, +} from './ai-assistant.actions'; + +export interface AiMessage { + role: 'user' | 'assistant'; + content: string; +} + +export interface AiChangeEntry { + group: string; + setting: string; + value: string; + previousValue: string | null; +} + +export interface AiChangeHistoryEntry { + userMessage: string; + designChanges: AiChangeEntry[]; + settingsChanges: AiChangeEntry[]; +} + +export interface AiAssistantStateModel { + isOpen: boolean; + messages: AiMessage[]; + isLoading: boolean; + changeHistory: AiChangeHistoryEntry[]; +} + +const defaults: AiAssistantStateModel = { + isOpen: false, + messages: [], + isLoading: false, + changeHistory: [], +}; + +@State({ + name: 'aiAssistant', + defaults, +}) +@Injectable() +export class AiAssistantState { + @Selector() + static isOpen(state: AiAssistantStateModel) { + return state.isOpen; + } + + @Selector() + static messages(state: AiAssistantStateModel) { + return state.messages; + } + + @Selector() + static isLoading(state: AiAssistantStateModel) { + return state.isLoading; + } + + constructor( + private store: Store, + private aiAssistantService: AiAssistantService, + ) {} + + @Action(ToggleAiAssistantAction) + toggle({ patchState, getState }: StateContext) { + patchState({ isOpen: !getState().isOpen }); + } + + @Action(SendAiMessageAction) + sendMessage( + { patchState, getState, dispatch }: StateContext, + action: SendAiMessageAction, + ) { + const state = getState(); + const userMessage: AiMessage = { role: 'user', content: action.message }; + patchState({ + messages: [...state.messages, userMessage], + isLoading: true, + }); + + const site = this.store.selectSnapshot(AppState.getSite) || ''; + const template = + this.store.selectSnapshot(SiteSettingsState.getCurrentSiteTemplate) || + ''; + const history = state.messages.map((m) => ({ + role: m.role, + content: m.content, + })); + + const changeHistoryPayload = state.changeHistory.map((entry) => ({ + user_message: entry.userMessage, + design_changes: entry.designChanges.map((c) => ({ + group: c.group, + setting: c.setting, + value: c.value, + previous_value: c.previousValue, + })), + settings_changes: entry.settingsChanges.map((c) => ({ + group: c.group, + setting: c.setting, + value: c.value, + previous_value: c.previousValue, + })), + })); + + return this.aiAssistantService + .chat(action.message, history, site, template, changeHistoryPayload) + .pipe( + tap((response) => { + dispatch( + new AiMessageReceivedAction(response.reply, response.design_changes, response.settings_changes, response.is_undo), + ); + }), + catchError((error) => { + console.error('AI assistant error:', error); + patchState({ isLoading: false }); + return EMPTY; + }), + ); + } + + @Action(AiMessageReceivedAction) + messageReceived( + { patchState, getState, dispatch }: StateContext, + action: AiMessageReceivedAction, + ) { + const state = getState(); + const assistantMessage: AiMessage = { + role: 'assistant', + content: action.reply, + }; + + let changeHistory: AiChangeHistoryEntry[]; + if (action.isUndo) { + changeHistory = state.changeHistory.slice(0, -1); + } else { + const lastUserMessage = [...state.messages].reverse().find((m) => m.role === 'user'); + const newEntry: AiChangeHistoryEntry = { + userMessage: lastUserMessage?.content ?? '', + designChanges: action.designChanges.map((c) => ({ + group: c.group, + setting: c.setting, + value: c.value, + previousValue: c.previous_value ?? null, + })), + settingsChanges: action.settingsChanges.map((c) => ({ + group: c.group, + setting: c.setting, + value: c.value, + previousValue: c.previous_value ?? null, + })), + }; + changeHistory = [...state.changeHistory, newEntry]; + } + + patchState({ + messages: [...state.messages, assistantMessage], + isLoading: false, + changeHistory, + }); + + for (const change of action.designChanges) { + dispatch( + new UpdateSiteTemplateSettingsAction(change.group, { + [change.setting]: change.value, + }), + ); + } + + for (const change of action.settingsChanges) { + dispatch( + new UpdateSiteSettingsAction(change.group, { + [change.setting]: change.value, + }), + ); + } + } + + @Action(ClearAiChatAction) + clearChat({ setState }: StateContext) { + setState(defaults); + } +} diff --git a/editor/src/app/app.component.ts b/editor/src/app/app.component.ts index 0ea2dc799..20a098fc5 100644 --- a/editor/src/app/app.component.ts +++ b/editor/src/app/app.component.ts @@ -53,6 +53,7 @@ import { AppStateService } from './app-state/app-state.service'; (click)="hideOverlay()" > + `, styles: [ ` diff --git a/editor/src/app/app.module.ts b/editor/src/app/app.module.ts index 2617d5e1a..e8dbd31de 100644 --- a/editor/src/app/app.module.ts +++ b/editor/src/app/app.module.ts @@ -36,6 +36,8 @@ import { MessyTemplateStyleService } from './preview/messy-template-style.servic import { SiteSectionsModule } from './sites/sections/site-sections.module'; import { ShopSettingsState } from './shop/settings/shop-settings.state'; import { ShopRegionalCostsState } from './shop/regional-costs/shop-regional-costs.state'; +import { AiAssistantModule } from './ai-assistant/ai-assistant.module'; +import { AiAssistantState } from './ai-assistant/ai-assistant.state'; import { SiteMediaModule } from './sites/media/site-media.module'; import { SentryConfigService } from './sentry/sentry-config.service'; import * as Sentry from '@sentry/angular'; @@ -67,6 +69,7 @@ import { sentryInitFactory } from './sentry/sentry-init.factory'; ErrorState, ShopSettingsState, ShopRegionalCostsState, + AiAssistantState, ], { developmentMode: !environment.production, @@ -78,6 +81,7 @@ import { sentryInitFactory } from './sentry/sentry-init.factory'; SitesSharedModule, SiteSectionsModule, SiteMediaModule, + AiAssistantModule, ], providers: [ SentryConfigService, diff --git a/editor/src/app/header/header.component.ts b/editor/src/app/header/header.component.ts index 570387ee6..f391c9597 100644 --- a/editor/src/app/header/header.component.ts +++ b/editor/src/app/header/header.component.ts @@ -4,6 +4,8 @@ import { Observable } from 'rxjs'; import { AppState } from '../app-state/app.state'; import { UserState } from '../user/user.state'; import { UserStateModel } from '../user/user.state.model'; +import { AiAssistantState } from '../ai-assistant/ai-assistant.state'; +import { ToggleAiAssistantAction } from '../ai-assistant/ai-assistant.actions'; @Component({ selector: 'berta-header', @@ -60,6 +62,14 @@ import { UserStateModel } from '../user/user.state.model'; Knowledge base + @if ((user$ | async).features.includes('ai_assistant')) { + AI + } } @@ -123,11 +133,18 @@ export class HeaderComponent { isLoggedIn$: Observable; isLoading$: Observable; isSetup$: Observable; + isAiOpen$: Observable; constructor(private store: Store) { this.user$ = this.store.select((state) => state.user); this.isLoggedIn$ = this.store.select(UserState.isLoggedIn); this.isLoading$ = this.store.select(AppState.getShowLoading); this.isSetup$ = this.store.select(AppState.isSetup); + this.isAiOpen$ = this.store.select(AiAssistantState.isOpen); + } + + toggleAiAssistant(event: Event) { + event.preventDefault(); + this.store.dispatch(new ToggleAiAssistantAction()); } } diff --git a/editor/src/app/pipes/markdown.pipe.ts b/editor/src/app/pipes/markdown.pipe.ts new file mode 100644 index 000000000..a03008d7b --- /dev/null +++ b/editor/src/app/pipes/markdown.pipe.ts @@ -0,0 +1,18 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { marked, Renderer } from 'marked'; + +@Pipe({ name: 'markdown', standalone: false }) +export class MarkdownPipe implements PipeTransform { + constructor(private sanitizer: DomSanitizer) {} + + transform(value: string): SafeHtml { + const renderer = new Renderer(); + renderer.link = ({ href, title, text }: { href: string; title?: string | null; text: string }) => { + const titleAttr = title ? ` title="${title}"` : ''; + return `${text}`; + }; + const html = marked.parse(value, { renderer }) as string; + return this.sanitizer.bypassSecurityTrustHtml(html); + } +} diff --git a/editor/src/app/pipes/pipes.module.ts b/editor/src/app/pipes/pipes.module.ts new file mode 100644 index 000000000..2292ca80b --- /dev/null +++ b/editor/src/app/pipes/pipes.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from '@angular/core'; +import { SafePipe } from './safe.pipe'; +import { MarkdownPipe } from './markdown.pipe'; + +@NgModule({ + declarations: [SafePipe, MarkdownPipe], + exports: [SafePipe, MarkdownPipe], +}) +export class PipesModule {} diff --git a/editor/src/app/pipes/pipe.ts b/editor/src/app/pipes/safe.pipe.ts similarity index 100% rename from editor/src/app/pipes/pipe.ts rename to editor/src/app/pipes/safe.pipe.ts diff --git a/editor/src/app/rerender/default-template-rerender.service.ts b/editor/src/app/rerender/default-template-rerender.service.ts index dfc53beee..89bf2f0b6 100644 --- a/editor/src/app/rerender/default-template-rerender.service.ts +++ b/editor/src/app/rerender/default-template-rerender.service.ts @@ -27,6 +27,7 @@ export class DefaultTemplateRerenderService extends TemplateRerenderService { banners: { id: 'siteBanners', dataKey: 'siteBanners' }, settings: { id: 'sectionFooter', dataKey: 'sectionFooter' }, entryLayout: { id: 'pageEntries', dataKey: 'entries' }, + siteTexts: { id: 'siteHeader', dataKey: 'siteHeader' }, }; constructor( diff --git a/editor/src/app/rerender/mashup/mashup-template-rerender.service.ts b/editor/src/app/rerender/mashup/mashup-template-rerender.service.ts index a3ab38fc6..24e73be7a 100644 --- a/editor/src/app/rerender/mashup/mashup-template-rerender.service.ts +++ b/editor/src/app/rerender/mashup/mashup-template-rerender.service.ts @@ -24,6 +24,7 @@ export class MashupTemplateRerenderService extends TemplateRerenderService { banners: { id: 'siteBanners', dataKey: 'siteBanners' }, settings: { id: 'sectionFooter', dataKey: 'sectionFooter' }, entryLayout: { id: 'pageEntries', dataKey: 'entries' }, + siteTexts: { id: 'siteHeader', dataKey: 'siteHeader' }, }; constructor( diff --git a/editor/src/app/rerender/messy/messy-template-rerender.service.ts b/editor/src/app/rerender/messy/messy-template-rerender.service.ts index c91a6478a..0a59683dd 100644 --- a/editor/src/app/rerender/messy/messy-template-rerender.service.ts +++ b/editor/src/app/rerender/messy/messy-template-rerender.service.ts @@ -25,6 +25,7 @@ export class MessyTemplateRerenderService extends TemplateRerenderService { banners: { id: 'siteBanners', dataKey: 'siteBanners' }, settings: { id: 'sectionFooter', dataKey: 'sectionFooter' }, entryLayout: { id: 'pageEntries', dataKey: 'entries' }, + siteTexts: { id: 'siteHeader', dataKey: 'siteHeader' }, }; constructor( diff --git a/editor/src/app/rerender/template-rerender.service.ts b/editor/src/app/rerender/template-rerender.service.ts index 2464c74ff..0dccb7e8b 100644 --- a/editor/src/app/rerender/template-rerender.service.ts +++ b/editor/src/app/rerender/template-rerender.service.ts @@ -59,12 +59,14 @@ export class TemplateRerenderService { private static readonly BANNERS_SETTINGS = 'banners'; private static readonly SETTINGS = 'settings'; private static readonly ENTRY_LAYOUT = 'entryLayout'; + private static readonly SITE_TEXTS = 'siteTexts'; protected static readonly COMMON_SETTING_GROUPS = [ TemplateRerenderService.SOCIAL_MEDIA_LINKS, TemplateRerenderService.SOCIAL_MEDIA_BTNS, TemplateRerenderService.BANNERS_SETTINGS, TemplateRerenderService.SETTINGS, TemplateRerenderService.ENTRY_LAYOUT, + TemplateRerenderService.SITE_TEXTS, ]; constructor( @@ -91,6 +93,10 @@ export class TemplateRerenderService { case TemplateRerenderService.SOCIAL_MEDIA_LINKS: case TemplateRerenderService.SOCIAL_MEDIA_BTNS: compList = info.socialMediaComp; + break; + case TemplateRerenderService.SITE_TEXTS: + if (info.siteTexts) { compList.push(info.siteTexts); } + break; } return compList; diff --git a/editor/src/app/rerender/types/components.ts b/editor/src/app/rerender/types/components.ts index 8018849de..f6f64027f 100644 --- a/editor/src/app/rerender/types/components.ts +++ b/editor/src/app/rerender/types/components.ts @@ -3,6 +3,7 @@ export interface SiteSettingChildrenHandler { banners: Component; settings: Component; entryLayout: Component; + siteTexts: Component; } export interface Component { diff --git a/editor/src/app/rerender/white/white-template-rerender.service.ts b/editor/src/app/rerender/white/white-template-rerender.service.ts index 7aa271ae1..6efcb05bb 100644 --- a/editor/src/app/rerender/white/white-template-rerender.service.ts +++ b/editor/src/app/rerender/white/white-template-rerender.service.ts @@ -24,6 +24,7 @@ export class WhiteTemplateRerenderService extends TemplateRerenderService { banners: { id: 'siteBanners', dataKey: 'siteBanners' }, settings: { id: 'sectionFooter', dataKey: 'sectionFooter' }, entryLayout: { id: 'pageEntries', dataKey: 'entries' }, + siteTexts: { id: 'siteHeader', dataKey: 'siteHeader' }, }; constructor( diff --git a/editor/src/app/sites/sections/site-sections.module.ts b/editor/src/app/sites/sections/site-sections.module.ts index 1f416eebd..7c4d84eff 100644 --- a/editor/src/app/sites/sections/site-sections.module.ts +++ b/editor/src/app/sites/sections/site-sections.module.ts @@ -4,7 +4,7 @@ import { RouterModule } from '@angular/router'; import { DragDropModule } from '@angular/cdk/drag-drop'; import { NgxsModule } from '@ngxs/store'; import { NgsgModule } from 'ng-sortgrid'; -import { SafePipe } from '../../pipes/pipe'; +import { PipesModule } from '../../pipes/pipes.module'; import { SiteSectionsState } from './sections-state/site-sections.state'; import { SectionTagsState } from './tags/section-tags.state'; import { SitesSharedModule } from '../shared/sites-shared.module'; @@ -22,13 +22,13 @@ import { BackgroundGalleryEditorComponent } from './background-gallery-editor.co NgxsModule.forFeature([SiteSectionsState, SectionTagsState]), SectionEntriesModule, SitesSharedModule, + PipesModule, ], - exports: [SafePipe], + exports: [PipesModule], declarations: [ SiteSectionsComponent, SectionComponent, BackgroundGalleryEditorComponent, - SafePipe, ], }) export class SiteSectionsModule {} diff --git a/editor/src/app/sites/settings/site-settings.state.ts b/editor/src/app/sites/settings/site-settings.state.ts index d25271b03..27df297cf 100644 --- a/editor/src/app/sites/settings/site-settings.state.ts +++ b/editor/src/app/sites/settings/site-settings.state.ts @@ -167,6 +167,7 @@ export class SiteSettingsState implements NgxsOnInit { case 'navigation': dispatch(new UpdateNavigationSiteSettingsAction(action.payload)); break; + case 'siteTexts': case 'socialMediaLinks': case 'socialMediaButtons': case 'media': diff --git a/editor/src/app/user/user.state.ts b/editor/src/app/user/user.state.ts index 51628e943..9131eff5b 100644 --- a/editor/src/app/user/user.state.ts +++ b/editor/src/app/user/user.state.ts @@ -29,6 +29,7 @@ import { ResetSiteSettingsAction } from '../sites/settings/site-settings.actions import { ResetSiteSettingsConfigAction } from '../sites/settings/site-settings-config.actions'; import { ResetSiteTemplateSettingsAction } from '../sites/template-settings/site-template-settings.actions'; import { ResetSiteTemplatesAction } from '../sites/template-settings/site-templates.actions'; +import { ClearAiChatAction } from '../ai-assistant/ai-assistant.actions'; import { Injectable } from '@angular/core'; import { of } from 'rxjs'; @@ -151,6 +152,7 @@ export class UserState implements NgxsOnInit { new ResetSiteSettingsConfigAction(), new ResetSiteTemplateSettingsAction(), new ResetSiteTemplatesAction(), + new ClearAiChatAction(), ]); if (action.saveNextUrl) { From a68ff4e1d50873fb08c53e8541005d5fe473c92e Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Wed, 18 Mar 2026 13:37:38 +0200 Subject: [PATCH 04/21] PHP 8.3 for github workflow --- .github/workflows/tests.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 60240d7fb..ecd859362 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,6 @@ name: Tests -on: ['push', 'pull_request'] +on: ["push", "pull_request"] jobs: laravel-tests: @@ -14,7 +14,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.2 + php-version: 8.3 tools: composer:v2 coverage: xdebug @@ -35,8 +35,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' + node-version: "20" + cache: "npm" cache-dependency-path: editor/package-lock.json - name: Install Dependencies From 6a7ecf2822bae595b33d43907bda35a68a699ac0 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Wed, 18 Mar 2026 14:38:33 +0200 Subject: [PATCH 05/21] Gitignore local AI config files, add example files with placeholder paths Four AI tool config files contained an absolute machine-specific path. They are now gitignored and replaced with .example counterparts using /path/to/berta/_api_app. Co-Authored-By: Claude Sonnet 4.6 --- _api_app/.codex/{config.toml => config.toml.example} | 6 +++--- _api_app/.cursor/{mcp.json => mcp.json.example} | 4 ++-- _api_app/.gitignore | 4 ++++ _api_app/{.mcp.json => .mcp.json.example} | 4 ++-- _api_app/{opencode.json => opencode.json.example} | 4 ++-- 5 files changed, 13 insertions(+), 9 deletions(-) rename _api_app/.codex/{config.toml => config.toml.example} (56%) rename _api_app/.cursor/{mcp.json => mcp.json.example} (83%) rename _api_app/{.mcp.json => .mcp.json.example} (83%) rename _api_app/{opencode.json => opencode.json.example} (88%) diff --git a/_api_app/.codex/config.toml b/_api_app/.codex/config.toml.example similarity index 56% rename from _api_app/.codex/config.toml rename to _api_app/.codex/config.toml.example index 9f0141cdc..ac5c8d0de 100644 --- a/_api_app/.codex/config.toml +++ b/_api_app/.codex/config.toml.example @@ -1,12 +1,12 @@ [mcp_servers.laravel-boost] command = "php" args = ["artisan", "boost:mcp"] -cwd = "/Users/uldis/projects/berta/berta/_api_app" +cwd = "/path/to/berta/_api_app" [mcp_servers.herd] command = "php" args = ["/Applications/Herd.app/Contents/Resources/herd-mcp.phar"] -cwd = "/Users/uldis/projects/berta/berta/_api_app" +cwd = "/path/to/berta/_api_app" [mcp_servers.herd.env] -SITE_PATH = "/Users/uldis/projects/berta/berta/_api_app" +SITE_PATH = "/path/to/berta/_api_app" diff --git a/_api_app/.cursor/mcp.json b/_api_app/.cursor/mcp.json.example similarity index 83% rename from _api_app/.cursor/mcp.json rename to _api_app/.cursor/mcp.json.example index a9816a027..aac22b26a 100644 --- a/_api_app/.cursor/mcp.json +++ b/_api_app/.cursor/mcp.json.example @@ -13,8 +13,8 @@ "/Applications/Herd.app/Contents/Resources/herd-mcp.phar" ], "env": { - "SITE_PATH": "/Users/uldis/projects/berta/berta/_api_app" + "SITE_PATH": "/path/to/berta/_api_app" } } } -} \ No newline at end of file +} diff --git a/_api_app/.gitignore b/_api_app/.gitignore index 46340a60a..fbb710106 100644 --- a/_api_app/.gitignore +++ b/_api_app/.gitignore @@ -18,3 +18,7 @@ yarn-error.log /.fleet /.idea /.vscode +.mcp.json +.cursor/mcp.json +.codex/config.toml +opencode.json diff --git a/_api_app/.mcp.json b/_api_app/.mcp.json.example similarity index 83% rename from _api_app/.mcp.json rename to _api_app/.mcp.json.example index a9816a027..aac22b26a 100644 --- a/_api_app/.mcp.json +++ b/_api_app/.mcp.json.example @@ -13,8 +13,8 @@ "/Applications/Herd.app/Contents/Resources/herd-mcp.phar" ], "env": { - "SITE_PATH": "/Users/uldis/projects/berta/berta/_api_app" + "SITE_PATH": "/path/to/berta/_api_app" } } } -} \ No newline at end of file +} diff --git a/_api_app/opencode.json b/_api_app/opencode.json.example similarity index 88% rename from _api_app/opencode.json rename to _api_app/opencode.json.example index c43fb5c84..dd9e8a0b2 100644 --- a/_api_app/opencode.json +++ b/_api_app/opencode.json.example @@ -18,8 +18,8 @@ "/Applications/Herd.app/Contents/Resources/herd-mcp.phar" ], "environment": { - "SITE_PATH": "/Users/uldis/projects/berta/berta/_api_app" + "SITE_PATH": "/path/to/berta/_api_app" } } } -} \ No newline at end of file +} From 821cd47930c530e20746e30a45d51edbc9133a9a Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Tue, 31 Mar 2026 10:43:31 +0300 Subject: [PATCH 06/21] Section operations for AI Assistant --- editor/package-lock.json | 12 +- .../app/ai-assistant/ai-assistant.actions.ts | 1 + .../app/ai-assistant/ai-assistant.service.ts | 13 +- .../app/ai-assistant/ai-assistant.state.ts | 254 +++++++++++++++++- 4 files changed, 263 insertions(+), 17 deletions(-) diff --git a/editor/package-lock.json b/editor/package-lock.json index cd44c41d0..1a7c49b87 100644 --- a/editor/package-lock.json +++ b/editor/package-lock.json @@ -4545,12 +4545,16 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.10", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz", - "integrity": "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==", + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", "dev": true, + "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/beasties": { diff --git a/editor/src/app/ai-assistant/ai-assistant.actions.ts b/editor/src/app/ai-assistant/ai-assistant.actions.ts index c97a42344..b27d4ff18 100644 --- a/editor/src/app/ai-assistant/ai-assistant.actions.ts +++ b/editor/src/app/ai-assistant/ai-assistant.actions.ts @@ -13,6 +13,7 @@ export class AiMessageReceivedAction { public reply: string, public designChanges: { group: string; setting: string; value: string; previous_value?: string | null }[], public settingsChanges: { group: string; setting: string; value: string; previous_value?: string | null }[], + public sectionChanges: { operation: string; name?: string; title?: string; property?: string; value?: string; previous_value?: string | null; order?: number }[], public isUndo: boolean = false, ) {} } diff --git a/editor/src/app/ai-assistant/ai-assistant.service.ts b/editor/src/app/ai-assistant/ai-assistant.service.ts index 06a586c86..bc5510a00 100644 --- a/editor/src/app/ai-assistant/ai-assistant.service.ts +++ b/editor/src/app/ai-assistant/ai-assistant.service.ts @@ -10,11 +10,22 @@ export interface AiChangeItem { previous_value?: string | null; } +export interface AiSectionChangeItem { + operation: 'create' | 'clone' | 'update' | 'delete' | 'reorder'; + name?: string; + title?: string; + property?: string; + value?: string; + previous_value?: string | null; + order?: number; +} + export interface AiChatResponse { reply: string; is_undo: boolean; design_changes: AiChangeItem[]; settings_changes: AiChangeItem[]; + section_changes: AiSectionChangeItem[]; } @Injectable({ @@ -28,7 +39,7 @@ export class AiAssistantService { history: { role: string; content: string }[], site: string, template: string, - changeHistory: { user_message: string; design_changes: AiChangeItem[]; settings_changes: AiChangeItem[] }[] = [], + changeHistory: { user_message: string; design_changes: AiChangeItem[]; settings_changes: AiChangeItem[]; section_changes: AiSectionChangeItem[] }[] = [], ): Observable { return this.appStateService .sync('aiChat', { message, history, site, template, change_history: changeHistory }, 'POST') diff --git a/editor/src/app/ai-assistant/ai-assistant.state.ts b/editor/src/app/ai-assistant/ai-assistant.state.ts index 824bbd96d..6d05c0f5f 100644 --- a/editor/src/app/ai-assistant/ai-assistant.state.ts +++ b/editor/src/app/ai-assistant/ai-assistant.state.ts @@ -1,14 +1,22 @@ import { Injectable } from '@angular/core'; import { State, Action, StateContext, Selector } from '@ngxs/store'; -import { tap, catchError } from 'rxjs/operators'; -import { EMPTY } from 'rxjs'; +import { tap, catchError, concatMap } from 'rxjs/operators'; +import { EMPTY, from, concat } from 'rxjs'; import { Store } from '@ngxs/store'; import { AppState } from '../app-state/app.state'; import { SiteSettingsState } from '../sites/settings/site-settings.state'; +import { SiteSectionsState } from '../sites/sections/sections-state/site-sections.state'; import { UpdateSiteTemplateSettingsAction } from '../sites/template-settings/site-template-settings.actions'; import { UpdateSiteSettingsAction } from '../sites/settings/site-settings.actions'; -import { AiAssistantService } from './ai-assistant.service'; +import { + CreateSectionAction, + UpdateSiteSectionAction, + RenameSiteSectionAction, + DeleteSiteSectionAction, + ReOrderSiteSectionsAction, +} from '../sites/sections/sections-state/site-sections.actions'; +import { AiAssistantService, AiSectionChangeItem as AiSectionChangeResponseItem } from './ai-assistant.service'; import { ToggleAiAssistantAction, SendAiMessageAction, @@ -21,24 +29,35 @@ export interface AiMessage { content: string; } -export interface AiChangeEntry { +export interface AiChangeItem { group: string; setting: string; value: string; previousValue: string | null; } -export interface AiChangeHistoryEntry { +export interface AiSectionChangeItem { + operation: string; + name?: string; + title?: string; + property?: string; + value?: string; + previousValue?: string | null; + order?: number; +} + +export interface AiChangeHistoryItem { userMessage: string; - designChanges: AiChangeEntry[]; - settingsChanges: AiChangeEntry[]; + designChanges: AiChangeItem[]; + settingsChanges: AiChangeItem[]; + sectionChanges: AiSectionChangeItem[]; } export interface AiAssistantStateModel { isOpen: boolean; messages: AiMessage[]; isLoading: boolean; - changeHistory: AiChangeHistoryEntry[]; + changeHistory: AiChangeHistoryItem[]; } const defaults: AiAssistantStateModel = { @@ -114,6 +133,15 @@ export class AiAssistantState { value: c.value, previous_value: c.previousValue, })), + section_changes: entry.sectionChanges.map((c) => ({ + operation: c.operation as AiSectionChangeResponseItem['operation'], + name: c.name, + title: c.title, + property: c.property, + value: c.value, + previous_value: c.previousValue, + order: c.order, + })), })); return this.aiAssistantService @@ -121,7 +149,7 @@ export class AiAssistantState { .pipe( tap((response) => { dispatch( - new AiMessageReceivedAction(response.reply, response.design_changes, response.settings_changes, response.is_undo), + new AiMessageReceivedAction(response.reply, response.design_changes, response.settings_changes, response.section_changes ?? [], response.is_undo), ); }), catchError((error) => { @@ -143,12 +171,15 @@ export class AiAssistantState { content: action.reply, }; - let changeHistory: AiChangeHistoryEntry[]; + const site = this.store.selectSnapshot(AppState.getSite) || ''; + const currentSections = this.store.selectSnapshot(SiteSectionsState.getCurrentSiteSections); + + let changeHistory: AiChangeHistoryItem[]; if (action.isUndo) { changeHistory = state.changeHistory.slice(0, -1); } else { const lastUserMessage = [...state.messages].reverse().find((m) => m.role === 'user'); - const newEntry: AiChangeHistoryEntry = { + const newItem: AiChangeHistoryItem = { userMessage: lastUserMessage?.content ?? '', designChanges: action.designChanges.map((c) => ({ group: c.group, @@ -162,8 +193,33 @@ export class AiAssistantState { value: c.value, previousValue: c.previous_value ?? null, })), + sectionChanges: action.sectionChanges.map((c) => { + if (c.operation === 'reorder') { + // Capture previousValue from frontend state (reliable) before the reorder dispatch + const section = currentSections.find((s) => s.name === c.name); + return { + operation: c.operation, + name: c.name, + previousValue: section != null ? String(section.order) : (c.previous_value ?? null), + order: c.order, + }; + } + return { + operation: c.operation, + name: c.name, + title: c.title, + property: c.property, + value: c.value, + previousValue: c.previous_value ?? null, + order: c.order, + }; + }), }; - changeHistory = [...state.changeHistory, newEntry]; + const hasChanges = + newItem.designChanges.length > 0 || + newItem.settingsChanges.length > 0 || + newItem.sectionChanges.length > 0; + changeHistory = hasChanges ? [...state.changeHistory, newItem] : state.changeHistory; } patchState({ @@ -187,6 +243,180 @@ export class AiAssistantState { }), ); } + + const lastHistoryItem = action.isUndo + ? state.changeHistory[state.changeHistory.length - 1] + : null; + + const createChanges = action.sectionChanges.filter((c) => c.operation === 'create'); + const cloneChanges = action.sectionChanges.filter((c) => c.operation === 'clone'); + const reorderChanges = action.sectionChanges.filter((c) => c.operation === 'reorder'); + + for (const change of action.sectionChanges) { + const { operation, name, property, value } = change; + + if (operation === 'create') { + // handled in createChain below + } else if (operation === 'update') { + let section = currentSections.find((s) => s.name === name); + + // Fallback for rename: after rename the section slug changes, so the old + // name no longer matches. Search change history for a prior rename of this + // name and find the section by its current title instead. + if (!section && property === 'title') { + for (let i = state.changeHistory.length - 1; i >= 0; i--) { + const histChange = state.changeHistory[i].sectionChanges.find( + (c) => c.operation === 'update' && c.property === 'title' && c.name === name, + ); + if (histChange?.value) { + section = currentSections.find((s) => s.title === histChange.value); + if (section) break; + } + } + } + + if (section && property) { + if (property === 'title') { + dispatch(new RenameSiteSectionAction(section, section.order, { title: value ?? '' })); + } else if (property === 'published') { + // Normalize to '0'/'1': AI may return 'yes'/'no' due to ambiguous prompt instructions + const publishedValue = (value === '1' || value === 'yes' || value === 'true') ? '1' : '0'; + dispatch(new UpdateSiteSectionAction(site, section.order, { '@attributes': { published: publishedValue } })); + } else if (property === 'type') { + dispatch(new UpdateSiteSectionAction(site, section.order, { '@attributes': { [property]: value ?? '' } })); + } else { + dispatch(new UpdateSiteSectionAction(site, section.order, { [property]: value ?? '' })); + } + } + } else if (operation === 'delete') { + let section = currentSections.find((s) => s.name === name); + + // Fallback: on undo of a create, the server assigns its own slug (e.g. untitled-1). + // After creation we store that server-assigned name in c.value. Try by value first, + // then fall back to matching by title. + if (!section && action.isUndo && lastHistoryItem) { + const histChange = lastHistoryItem.sectionChanges.find( + (c) => c.operation === 'create' && (c.name === name || c.value === name), + ); + if (histChange?.value) { + section = currentSections.find((s) => s.name === histChange.value); + } + if (!section && histChange?.title) { + section = currentSections.find((s) => s.title === histChange.title); + } + } + + // Fallback: on undo of a clone, find the cloned section by the name stored in history + if (!section && action.isUndo && lastHistoryItem) { + const cloneHistChange = lastHistoryItem.sectionChanges.find( + (c) => c.operation === 'clone' && (c.name === name || c.value === name), + ); + if (cloneHistChange?.value) { + section = currentSections.find((s) => s.name === cloneHistChange.value); + } + } + + if (section) { + dispatch(new DeleteSiteSectionAction(section)); + } + } + } + + // Create dispatches — sequential, capturing the server-assigned name for undo support + const createChain = from(createChanges).pipe( + concatMap((change) => { + const sectionsBefore = this.store.selectSnapshot(SiteSectionsState.getCurrentSiteSections); + return dispatch(new CreateSectionAction({ name: null, title: change.title ?? change.name } as any)).pipe( + tap(() => { + if (!action.isUndo) { + const sectionsAfter = this.store.selectSnapshot(SiteSectionsState.getCurrentSiteSections); + const newSection = sectionsAfter.find((s) => !sectionsBefore.some((b) => b.name === s.name)); + if (newSection) { + const currentHistory = getState().changeHistory; + patchState({ + changeHistory: currentHistory.map((entry, idx) => { + if (idx !== currentHistory.length - 1) return entry; + return { + ...entry, + sectionChanges: entry.sectionChanges.map((c) => + c.operation === 'create' && c.name === change.name + ? { ...c, value: newSection.name } + : c, + ), + }; + }), + }); + } + } + }), + ); + }), + ); + + // Clone dispatches — sequential, capturing the server-assigned name for undo support. + // Dispatch CreateSectionAction directly (not CloneSectionAction) because CloneSectionAction + // doesn't return the inner Observable, causing dispatch() to complete before the HTTP call + // finishes and making the tap() snapshot miss the newly created section. + const cloneChain = from(cloneChanges).pipe( + concatMap((change) => { + const sectionsBefore = this.store.selectSnapshot(SiteSectionsState.getCurrentSiteSections); + const sourceSection = sectionsBefore.find((s) => s.name === change.name); + if (!sourceSection) return EMPTY; + + return dispatch(new CreateSectionAction(sourceSection)).pipe( + tap(() => { + if (!action.isUndo) { + // Detect the newly created section and store its name in history for undo + const sectionsAfter = this.store.selectSnapshot(SiteSectionsState.getCurrentSiteSections); + const newSection = sectionsAfter.find((s) => !sectionsBefore.some((b) => b.name === s.name)); + if (newSection) { + const currentHistory = getState().changeHistory; + patchState({ + changeHistory: currentHistory.map((entry, idx) => { + if (idx !== currentHistory.length - 1) return entry; + return { + ...entry, + sectionChanges: entry.sectionChanges.map((c) => + c.operation === 'clone' && c.name === change.name + ? { ...c, value: newSection.name } + : c, + ), + }; + }), + }); + } + } + }), + ); + }), + ); + + const reorderChain = from(reorderChanges).pipe( + concatMap((change) => { + // Snapshot live state at THIS moment so we see updates from prior reorders + const liveSection = this.store + .selectSnapshot(SiteSectionsState.getCurrentSiteSections) + .find((s) => s.name === change.name); + + if (!liveSection) return EMPTY; + + let targetOrder: number | undefined = change.order; + if (action.isUndo && lastHistoryItem) { + const histChange = lastHistoryItem.sectionChanges.find( + (c) => c.operation === 'reorder' && c.name === change.name, + ); + if (histChange?.previousValue != null) { + targetOrder = parseInt(histChange.previousValue, 10); + } + } + + if (targetOrder === undefined) return EMPTY; + + return dispatch(new ReOrderSiteSectionsAction(liveSection.order, targetOrder)); + }), + ); + + return concat(createChain, cloneChain, reorderChain); } @Action(ClearAiChatAction) From c05d07a5a98d8c128602c0aa05c15a7f151b6bff Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Wed, 1 Apr 2026 16:50:26 +0300 Subject: [PATCH 07/21] Section Entries operations for AI Assistant --- .../tests/Feature/AiChatControllerTest.php | 86 ++++++++++++ .../app/ai-assistant/ai-assistant.actions.ts | 1 + .../app/ai-assistant/ai-assistant.service.ts | 12 +- .../app/ai-assistant/ai-assistant.state.ts | 132 ++++++++++++++++-- 4 files changed, 222 insertions(+), 9 deletions(-) diff --git a/_api_app/tests/Feature/AiChatControllerTest.php b/_api_app/tests/Feature/AiChatControllerTest.php index f8d2cea32..bf8638b99 100644 --- a/_api_app/tests/Feature/AiChatControllerTest.php +++ b/_api_app/tests/Feature/AiChatControllerTest.php @@ -1,6 +1,7 @@ toBe(''); })->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); +it('parses entry_changes from ai response', function () { + AssistantAgent::fake([ + '{"reply": "Created an entry.", "is_undo": false, "design_changes": [], "settings_changes": [], "section_changes": [], "entry_changes": [{"operation": "create", "section": "blog", "description": "

Hello world

"}]}', + ]); + + $agent = new AssistantAgent('system prompt'); + $result = $agent->chat([['role' => 'user', 'content' => 'add an entry to blog']]); + + expect($result['entry_changes'])->toHaveCount(1) + ->and($result['entry_changes'][0]['operation'])->toBe('create') + ->and($result['entry_changes'][0]['section'])->toBe('blog') + ->and($result['entry_changes'][0]['description'])->toBe('

Hello world

'); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('returns empty entry_changes when not in ai response', function () { + AssistantAgent::fake([ + '{"reply": "Done.", "design_changes": [], "settings_changes": [], "section_changes": []}', + ]); + + $agent = new AssistantAgent('system prompt'); + $result = $agent->chat([['role' => 'user', 'content' => 'hello']]); + + expect($result['entry_changes'])->toBeArray()->toBeEmpty(); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('includes entry changes in change history section', function () { + $controller = new AiChatController; + $method = new ReflectionMethod($controller, 'buildChangeHistorySection'); + + $changeHistory = [ + [ + 'user_message' => 'add an entry', + 'design_changes' => [], + 'settings_changes' => [], + 'section_changes' => [], + 'entry_changes' => [ + ['operation' => 'create', 'section' => 'blog', 'entry_id' => '5', 'description' => '

Hello

'], + ], + ], + [ + 'user_message' => 'update entry description', + 'design_changes' => [], + 'settings_changes' => [], + 'section_changes' => [], + 'entry_changes' => [ + ['operation' => 'update', 'section' => 'blog', 'entry_id' => '5', 'value' => '

New

', 'previous_value' => '

Old

'], + ], + ], + ]; + + $result = $method->invoke($controller, $changeHistory); + + expect($result) + ->toContain('add an entry') + ->toContain('entry: create #5 in "blog"') + ->toContain('undo: delete entry 5') + ->toContain('update entry description') + ->toContain('entry: update #5 in "blog"') + ->toContain('Old') + ->toContain('New'); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('restricts change_history entry_changes operation to create and update', function () { + $request = new AiChatRequest; + $rules = $request->rules(); + + expect($rules['change_history.*.entry_changes.*.operation']) + ->toContain('in:create,update'); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('includes entries section in system prompt', function () { + $controller = new AiChatController; + $method = new ReflectionMethod($controller, 'buildEntriesSection'); + + $result = $method->invoke($controller); + + expect($result) + ->toContain('Entries') + ->toContain('list_section_entries') + ->toContain('get_entry_content') + ->toContain('description') + ->toContain('delete') + ->toContain('manually'); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + it('includes help articles in system prompt', function () { $controller = new AiChatController; $method = new ReflectionMethod($controller, 'buildHelpArticlesSection'); diff --git a/editor/src/app/ai-assistant/ai-assistant.actions.ts b/editor/src/app/ai-assistant/ai-assistant.actions.ts index b27d4ff18..0984a3cf6 100644 --- a/editor/src/app/ai-assistant/ai-assistant.actions.ts +++ b/editor/src/app/ai-assistant/ai-assistant.actions.ts @@ -15,6 +15,7 @@ export class AiMessageReceivedAction { public settingsChanges: { group: string; setting: string; value: string; previous_value?: string | null }[], public sectionChanges: { operation: string; name?: string; title?: string; property?: string; value?: string; previous_value?: string | null; order?: number }[], public isUndo: boolean = false, + public entryChanges: { operation: 'create' | 'update' | 'delete'; section?: string; entry_id?: string; value?: string; previous_value?: string | null; description?: string }[] = [], ) {} } diff --git a/editor/src/app/ai-assistant/ai-assistant.service.ts b/editor/src/app/ai-assistant/ai-assistant.service.ts index bc5510a00..c7f93a18b 100644 --- a/editor/src/app/ai-assistant/ai-assistant.service.ts +++ b/editor/src/app/ai-assistant/ai-assistant.service.ts @@ -20,12 +20,22 @@ export interface AiSectionChangeItem { order?: number; } +export interface AiEntryChangeItem { + operation: 'create' | 'update' | 'delete'; + section?: string; + entry_id?: string; + value?: string; + previous_value?: string | null; + description?: string; +} + export interface AiChatResponse { reply: string; is_undo: boolean; design_changes: AiChangeItem[]; settings_changes: AiChangeItem[]; section_changes: AiSectionChangeItem[]; + entry_changes: AiEntryChangeItem[]; } @Injectable({ @@ -39,7 +49,7 @@ export class AiAssistantService { history: { role: string; content: string }[], site: string, template: string, - changeHistory: { user_message: string; design_changes: AiChangeItem[]; settings_changes: AiChangeItem[]; section_changes: AiSectionChangeItem[] }[] = [], + changeHistory: { user_message: string; design_changes: AiChangeItem[]; settings_changes: AiChangeItem[]; section_changes: AiSectionChangeItem[]; entry_changes: AiEntryChangeItem[] }[] = [], ): Observable { return this.appStateService .sync('aiChat', { message, history, site, template, change_history: changeHistory }, 'POST') diff --git a/editor/src/app/ai-assistant/ai-assistant.state.ts b/editor/src/app/ai-assistant/ai-assistant.state.ts index 6d05c0f5f..da9538a66 100644 --- a/editor/src/app/ai-assistant/ai-assistant.state.ts +++ b/editor/src/app/ai-assistant/ai-assistant.state.ts @@ -16,7 +16,13 @@ import { DeleteSiteSectionAction, ReOrderSiteSectionsAction, } from '../sites/sections/sections-state/site-sections.actions'; -import { AiAssistantService, AiSectionChangeItem as AiSectionChangeResponseItem } from './ai-assistant.service'; +import { + AddSectionEntryFromSyncAction, + UpdateSectionEntryFromSyncAction, + DeleteSectionEntryFromSyncAction, +} from '../sites/sections/entries/entries-state/section-entries.actions'; +import { SectionEntriesState } from '../sites/sections/entries/entries-state/section-entries.state'; +import { AiAssistantService, AiEntryChangeItem as AiEntryChangeResponseItem, AiSectionChangeItem as AiSectionChangeResponseItem } from './ai-assistant.service'; import { ToggleAiAssistantAction, SendAiMessageAction, @@ -46,11 +52,21 @@ export interface AiSectionChangeItem { order?: number; } +export interface AiEntryChangeItem { + operation: string; + section?: string; + entryId?: string; + value?: string; + previousValue?: string | null; + description?: string; +} + export interface AiChangeHistoryItem { userMessage: string; designChanges: AiChangeItem[]; settingsChanges: AiChangeItem[]; sectionChanges: AiSectionChangeItem[]; + entryChanges: AiEntryChangeItem[]; } export interface AiAssistantStateModel { @@ -142,6 +158,13 @@ export class AiAssistantState { previous_value: c.previousValue, order: c.order, })), + entry_changes: entry.entryChanges.map((c) => ({ + operation: c.operation as AiEntryChangeResponseItem['operation'], + section: c.section, + entry_id: c.entryId, + value: c.value, + previous_value: c.previousValue, + })), })); return this.aiAssistantService @@ -149,7 +172,7 @@ export class AiAssistantState { .pipe( tap((response) => { dispatch( - new AiMessageReceivedAction(response.reply, response.design_changes, response.settings_changes, response.section_changes ?? [], response.is_undo), + new AiMessageReceivedAction(response.reply, response.design_changes, response.settings_changes, response.section_changes ?? [], response.is_undo, response.entry_changes ?? []), ); }), catchError((error) => { @@ -214,11 +237,20 @@ export class AiAssistantState { order: c.order, }; }), + entryChanges: action.entryChanges.map((c) => ({ + operation: c.operation, + section: c.section, + entryId: c.entry_id, + value: c.value, + previousValue: c.previous_value ?? null, + description: c.description, + })), }; const hasChanges = newItem.designChanges.length > 0 || newItem.settingsChanges.length > 0 || - newItem.sectionChanges.length > 0; + newItem.sectionChanges.length > 0 || + newItem.entryChanges.length > 0; changeHistory = hasChanges ? [...state.changeHistory, newItem] : state.changeHistory; } @@ -322,16 +354,23 @@ export class AiAssistantState { } } + // Map from AI-proposed section name → server-assigned slug, populated during createChain. + // Used by createEntryChain to target the correct section even when the slug differs. + const sectionNameMap: Record = {}; + // Create dispatches — sequential, capturing the server-assigned name for undo support const createChain = from(createChanges).pipe( concatMap((change) => { const sectionsBefore = this.store.selectSnapshot(SiteSectionsState.getCurrentSiteSections); return dispatch(new CreateSectionAction({ name: null, title: change.title ?? change.name } as any)).pipe( tap(() => { - if (!action.isUndo) { - const sectionsAfter = this.store.selectSnapshot(SiteSectionsState.getCurrentSiteSections); - const newSection = sectionsAfter.find((s) => !sectionsBefore.some((b) => b.name === s.name)); - if (newSection) { + const sectionsAfter = this.store.selectSnapshot(SiteSectionsState.getCurrentSiteSections); + const newSection = sectionsAfter.find((s) => !sectionsBefore.some((b) => b.name === s.name)); + if (newSection) { + // Record mapping so createEntryChain can resolve the correct section name + sectionNameMap[change.name] = newSection.name; + + if (!action.isUndo) { const currentHistory = getState().changeHistory; patchState({ changeHistory: currentHistory.map((entry, idx) => { @@ -416,7 +455,84 @@ export class AiAssistantState { }), ); - return concat(createChain, cloneChain, reorderChain); + // Entry: update dispatches — each update targets content/description + const updateEntryChanges = action.entryChanges.filter((c) => c.operation === 'update'); + for (const change of updateEntryChanges) { + if (change.entry_id && change.section) { + dispatch( + new UpdateSectionEntryFromSyncAction( + `${site}/entry/${change.section}/${change.entry_id}/content/description`, + change.value ?? '', + ), + ); + } + } + + // Entry: delete dispatches (used for undo of creates) + const deleteEntryChanges = action.entryChanges.filter((c) => c.operation === 'delete'); + for (const change of deleteEntryChanges) { + if (change.entry_id && change.section) { + dispatch(new DeleteSectionEntryFromSyncAction(site, change.section, change.entry_id)); + } + } + + // Entry: create dispatches — sequential, capturing server-assigned entry ID for undo + const createEntryChanges = action.entryChanges.filter((c) => c.operation === 'create'); + const createEntryChain = from(createEntryChanges).pipe( + concatMap((change) => { + if (!change.section) return EMPTY; + // Resolve the actual server-assigned section name (in case it was just created above) + const targetSection = sectionNameMap[change.section] ?? change.section; + const entriesBefore = this.store + .selectSnapshot(SectionEntriesState.getCurrentSiteEntries) + .filter((e) => e.sectionName === targetSection); + + return dispatch( + new AddSectionEntryFromSyncAction(site, targetSection, { tag: null, before_entry: null }), + ).pipe( + concatMap(() => { + const newEntry = this.store + .selectSnapshot(SectionEntriesState.getCurrentSiteEntries) + .find( + (e) => + e.sectionName === targetSection && + !entriesBefore.some((b) => b.id === e.id), + ); + + if (!newEntry) return EMPTY; + + // Store the server-assigned entry ID in change history for undo + if (!action.isUndo) { + const currentHistory = getState().changeHistory; + patchState({ + changeHistory: currentHistory.map((entry, idx) => { + if (idx !== currentHistory.length - 1) return entry; + return { + ...entry, + entryChanges: entry.entryChanges.map((c) => + c.operation === 'create' && c.section === change.section && !c.entryId + ? { ...c, entryId: String(newEntry.id) } + : c, + ), + }; + }), + }); + } + + if (!change.description) return EMPTY; + + return dispatch( + new UpdateSectionEntryFromSyncAction( + `${site}/entry/${targetSection}/${newEntry.id}/content/description`, + change.description, + ), + ); + }), + ); + }), + ); + + return concat(createChain, cloneChain, reorderChain, createEntryChain); } @Action(ClearAiChatAction) From 990c3f91449cfd6696f54a40e1b30803ed6e191f Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Thu, 2 Apr 2026 16:13:47 +0300 Subject: [PATCH 08/21] Entry Gallerey operations and info for AI Assistant --- .../tests/Feature/AiChatControllerTest.php | 88 +++++++++++++++++++ .../app/ai-assistant/ai-assistant.actions.ts | 1 + .../app/ai-assistant/ai-assistant.service.ts | 13 ++- .../app/ai-assistant/ai-assistant.state.ts | 57 +++++++++++- 4 files changed, 155 insertions(+), 4 deletions(-) diff --git a/_api_app/tests/Feature/AiChatControllerTest.php b/_api_app/tests/Feature/AiChatControllerTest.php index bf8638b99..28c6ca78a 100644 --- a/_api_app/tests/Feature/AiChatControllerTest.php +++ b/_api_app/tests/Feature/AiChatControllerTest.php @@ -247,3 +247,91 @@ ->toContain('Domains') ->toContain('SSL Certificates and HTTPS'); })->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('parses gallery_changes from ai response', function () { + AssistantAgent::fake([ + '{"reply": "Updated gallery type.", "is_undo": false, "design_changes": [], "settings_changes": [], "section_changes": [], "entry_changes": [], "gallery_changes": [{"operation": "update_setting", "section": "portfolio", "entry_id": "3", "setting": "type", "value": "row"}]}', + ]); + + $agent = new AssistantAgent('system prompt'); + $result = $agent->chat([['role' => 'user', 'content' => 'change gallery to row']]); + + expect($result['gallery_changes'])->toHaveCount(1) + ->and($result['gallery_changes'][0]['operation'])->toBe('update_setting') + ->and($result['gallery_changes'][0]['section'])->toBe('portfolio') + ->and($result['gallery_changes'][0]['entry_id'])->toBe('3') + ->and($result['gallery_changes'][0]['setting'])->toBe('type') + ->and($result['gallery_changes'][0]['value'])->toBe('row'); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('returns empty gallery_changes when not in ai response', function () { + AssistantAgent::fake([ + '{"reply": "Done.", "design_changes": [], "settings_changes": [], "section_changes": []}', + ]); + + $agent = new AssistantAgent('system prompt'); + $result = $agent->chat([['role' => 'user', 'content' => 'hello']]); + + expect($result['gallery_changes'])->toBeArray()->toBeEmpty(); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('includes gallery changes in change history section', function () { + $controller = new AiChatController; + $method = new ReflectionMethod($controller, 'buildChangeHistorySection'); + + $changeHistory = [ + [ + 'user_message' => 'change gallery to row', + 'design_changes' => [], + 'settings_changes' => [], + 'section_changes' => [], + 'entry_changes' => [], + 'gallery_changes' => [ + ['operation' => 'update_setting', 'section' => 'portfolio', 'entry_id' => '3', 'setting' => 'type', 'value' => 'row', 'previous_value' => 'slideshow'], + ], + ], + [ + 'user_message' => 'update image caption', + 'design_changes' => [], + 'settings_changes' => [], + 'section_changes' => [], + 'entry_changes' => [], + 'gallery_changes' => [ + ['operation' => 'update_caption', 'section' => 'portfolio', 'entry_id' => '3', 'file_index' => 0, 'value' => '

New caption

', 'previous_value' => '

Old caption

'], + ], + ], + ]; + + $result = $method->invoke($controller, $changeHistory); + + expect($result) + ->toContain('gallery: update_setting entry #3 in "portfolio"') + ->toContain('"slideshow" → "row"') + ->toContain('gallery: update_caption entry #3 in "portfolio" file[0]') + ->toContain('"Old caption" → "New caption"'); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('restricts change_history gallery_changes operation to update_setting and update_caption', function () { + $request = new AiChatRequest; + $rules = $request->rules(); + + expect($rules['change_history.*.gallery_changes.*.operation']) + ->toContain('in:update_setting,update_caption'); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('includes galleries section in system prompt', function () { + $controller = new AiChatController; + $method = new ReflectionMethod($controller, 'buildGalleriesSection'); + + $result = $method->invoke($controller); + + expect($result) + ->toContain('Galleries') + ->toContain('get_entry_gallery') + ->toContain('update_setting') + ->toContain('update_caption') + ->toContain('slideshow') + ->toContain('fullscreen') + ->toContain('file_index') + ->toContain('manual'); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/editor/src/app/ai-assistant/ai-assistant.actions.ts b/editor/src/app/ai-assistant/ai-assistant.actions.ts index 0984a3cf6..54cef8f76 100644 --- a/editor/src/app/ai-assistant/ai-assistant.actions.ts +++ b/editor/src/app/ai-assistant/ai-assistant.actions.ts @@ -16,6 +16,7 @@ export class AiMessageReceivedAction { public sectionChanges: { operation: string; name?: string; title?: string; property?: string; value?: string; previous_value?: string | null; order?: number }[], public isUndo: boolean = false, public entryChanges: { operation: 'create' | 'update' | 'delete'; section?: string; entry_id?: string; value?: string; previous_value?: string | null; description?: string }[] = [], + public galleryChanges: { operation: 'update_setting' | 'update_caption'; section?: string; entry_id?: string; setting?: string; file_index?: number; value?: string; previous_value?: string | null }[] = [], ) {} } diff --git a/editor/src/app/ai-assistant/ai-assistant.service.ts b/editor/src/app/ai-assistant/ai-assistant.service.ts index c7f93a18b..5f3c0ec05 100644 --- a/editor/src/app/ai-assistant/ai-assistant.service.ts +++ b/editor/src/app/ai-assistant/ai-assistant.service.ts @@ -29,6 +29,16 @@ export interface AiEntryChangeItem { description?: string; } +export interface AiGalleryChangeItem { + operation: 'update_setting' | 'update_caption'; + section?: string; + entry_id?: string; + setting?: string; + file_index?: number; + value?: string; + previous_value?: string | null; +} + export interface AiChatResponse { reply: string; is_undo: boolean; @@ -36,6 +46,7 @@ export interface AiChatResponse { settings_changes: AiChangeItem[]; section_changes: AiSectionChangeItem[]; entry_changes: AiEntryChangeItem[]; + gallery_changes: AiGalleryChangeItem[]; } @Injectable({ @@ -49,7 +60,7 @@ export class AiAssistantService { history: { role: string; content: string }[], site: string, template: string, - changeHistory: { user_message: string; design_changes: AiChangeItem[]; settings_changes: AiChangeItem[]; section_changes: AiSectionChangeItem[]; entry_changes: AiEntryChangeItem[] }[] = [], + changeHistory: { user_message: string; design_changes: AiChangeItem[]; settings_changes: AiChangeItem[]; section_changes: AiSectionChangeItem[]; entry_changes: AiEntryChangeItem[]; gallery_changes: AiGalleryChangeItem[] }[] = [], ): Observable { return this.appStateService .sync('aiChat', { message, history, site, template, change_history: changeHistory }, 'POST') diff --git a/editor/src/app/ai-assistant/ai-assistant.state.ts b/editor/src/app/ai-assistant/ai-assistant.state.ts index da9538a66..fb1fb1cea 100644 --- a/editor/src/app/ai-assistant/ai-assistant.state.ts +++ b/editor/src/app/ai-assistant/ai-assistant.state.ts @@ -22,7 +22,7 @@ import { DeleteSectionEntryFromSyncAction, } from '../sites/sections/entries/entries-state/section-entries.actions'; import { SectionEntriesState } from '../sites/sections/entries/entries-state/section-entries.state'; -import { AiAssistantService, AiEntryChangeItem as AiEntryChangeResponseItem, AiSectionChangeItem as AiSectionChangeResponseItem } from './ai-assistant.service'; +import { AiAssistantService, AiEntryChangeItem as AiEntryChangeResponseItem, AiGalleryChangeItem as AiGalleryChangeResponseItem, AiSectionChangeItem as AiSectionChangeResponseItem } from './ai-assistant.service'; import { ToggleAiAssistantAction, SendAiMessageAction, @@ -61,12 +61,23 @@ export interface AiEntryChangeItem { description?: string; } +export interface AiGalleryChangeItem { + operation: string; + section?: string; + entryId?: string; + setting?: string; + fileIndex?: number; + value?: string; + previousValue?: string | null; +} + export interface AiChangeHistoryItem { userMessage: string; designChanges: AiChangeItem[]; settingsChanges: AiChangeItem[]; sectionChanges: AiSectionChangeItem[]; entryChanges: AiEntryChangeItem[]; + galleryChanges: AiGalleryChangeItem[]; } export interface AiAssistantStateModel { @@ -165,6 +176,15 @@ export class AiAssistantState { value: c.value, previous_value: c.previousValue, })), + gallery_changes: entry.galleryChanges.map((c) => ({ + operation: c.operation as AiGalleryChangeResponseItem['operation'], + section: c.section, + entry_id: c.entryId, + setting: c.setting, + file_index: c.fileIndex, + value: c.value, + previous_value: c.previousValue, + })), })); return this.aiAssistantService @@ -172,7 +192,7 @@ export class AiAssistantState { .pipe( tap((response) => { dispatch( - new AiMessageReceivedAction(response.reply, response.design_changes, response.settings_changes, response.section_changes ?? [], response.is_undo, response.entry_changes ?? []), + new AiMessageReceivedAction(response.reply, response.design_changes, response.settings_changes, response.section_changes ?? [], response.is_undo, response.entry_changes ?? [], response.gallery_changes ?? []), ); }), catchError((error) => { @@ -245,12 +265,22 @@ export class AiAssistantState { previousValue: c.previous_value ?? null, description: c.description, })), + galleryChanges: action.galleryChanges.map((c) => ({ + operation: c.operation, + section: c.section, + entryId: c.entry_id, + setting: c.setting, + fileIndex: c.file_index, + value: c.value, + previousValue: c.previous_value ?? null, + })), }; const hasChanges = newItem.designChanges.length > 0 || newItem.settingsChanges.length > 0 || newItem.sectionChanges.length > 0 || - newItem.entryChanges.length > 0; + newItem.entryChanges.length > 0 || + newItem.galleryChanges.length > 0; changeHistory = hasChanges ? [...state.changeHistory, newItem] : state.changeHistory; } @@ -476,6 +506,27 @@ export class AiAssistantState { } } + // Gallery: update gallery settings and file captions + for (const change of action.galleryChanges) { + if (!change.entry_id || !change.section) continue; + + if (change.operation === 'update_setting' && change.setting) { + dispatch( + new UpdateSectionEntryFromSyncAction( + `${site}/entry/${change.section}/${change.entry_id}/mediaCacheData/@attributes/${change.setting}`, + change.value ?? '', + ), + ); + } else if (change.operation === 'update_caption' && change.file_index != null) { + dispatch( + new UpdateSectionEntryFromSyncAction( + `${site}/entry/${change.section}/${change.entry_id}/mediaCacheData/file/${change.file_index}/@value`, + change.value ?? '', + ), + ); + } + } + // Entry: create dispatches — sequential, capturing server-assigned entry ID for undo const createEntryChanges = action.entryChanges.filter((c) => c.operation === 'create'); const createEntryChain = from(createEntryChanges).pipe( From a067bcfeb1dbfa69260ddc5773f25ea88a9cb0b7 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Fri, 10 Apr 2026 09:57:03 +0300 Subject: [PATCH 09/21] Create section slug by title --- .../Sections/SiteSectionsDataService.php | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/_api_app/app/Sites/Sections/SiteSectionsDataService.php b/_api_app/app/Sites/Sections/SiteSectionsDataService.php index d377bf30a..b627d615a 100644 --- a/_api_app/app/Sites/Sections/SiteSectionsDataService.php +++ b/_api_app/app/Sites/Sections/SiteSectionsDataService.php @@ -213,7 +213,11 @@ public function getState() public function create($name = null, $title = null) { - $name = $name ? $name : 'untitled-' . uniqid(); + if (!$name && $title) { + $name = $this->getUniqueSlug($title); + } + + $name = $name ?: 'untitled-' . uniqid(); $sections = $this->get(); $section_order = array_search($name, array_column($sections, 'name')); @@ -319,7 +323,7 @@ public function saveValueByPath($path, $value) if ($prop === 'title') { $old_name = $sectionName; $old_title = isset($sections['section'][$order]['title']) ? $sections['section'][$order]['title'] : ''; - $new_name = $this->getUniqueSlug($old_name, $value); + $new_name = $this->getUniqueSlug($value); if (empty($value)) { $ret['value'] = $old_title; @@ -682,21 +686,18 @@ public function backgroundGalleryUpload($path, $file) * Private methods ************************************************************/ - private function getUniqueSlug($old_name, $new_title) + private function getUniqueSlug($title) { $sections['section'] = $this->get(); - $title = trim($new_title); + $title = trim($title); if (strlen($title) < 1) { return ''; } - $slug = Helpers::slugify($new_title, '-', '\._-', true); - $slug = $slug ? $slug : '_'; - + $slug = Helpers::slugify($title, '-', '\._-', true); + $slug = $slug ?: '_'; $names = array_values(array_column($sections['section'], 'name')); - $old_title_idx = array_search($old_name, $names); - $_ = array_splice($names, $old_title_idx, 1); $notUnique = true; $i = 1; From 733347c540a6b02bfd9752791960cc4866b32da9 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Fri, 10 Apr 2026 10:12:06 +0300 Subject: [PATCH 10/21] Laravel/boost upgrade --- .../skills/laravel-best-practices/SKILL.md | 190 ++++++++++++++++ .../rules/advanced-queries.md | 106 +++++++++ .../rules/architecture.md | 202 ++++++++++++++++++ .../rules/blade-views.md | 36 ++++ .../laravel-best-practices/rules/caching.md | 70 ++++++ .../rules/collections.md | 44 ++++ .../laravel-best-practices/rules/config.md | 73 +++++++ .../rules/db-performance.md | 192 +++++++++++++++++ .../laravel-best-practices/rules/eloquent.md | 148 +++++++++++++ .../rules/error-handling.md | 72 +++++++ .../rules/events-notifications.md | 48 +++++ .../rules/http-client.md | 160 ++++++++++++++ .../laravel-best-practices/rules/mail.md | 27 +++ .../rules/migrations.md | 121 +++++++++++ .../rules/queue-jobs.md | 146 +++++++++++++ .../laravel-best-practices/rules/routing.md | 98 +++++++++ .../rules/scheduling.md | 39 ++++ .../laravel-best-practices/rules/security.md | 198 +++++++++++++++++ .../laravel-best-practices/rules/style.md | 125 +++++++++++ .../laravel-best-practices/rules/testing.md | 43 ++++ .../rules/validation.md | 75 +++++++ _api_app/.agents/skills/pest-testing/SKILL.md | 13 +- .../skills/laravel-best-practices/SKILL.md | 190 ++++++++++++++++ .../rules/advanced-queries.md | 106 +++++++++ .../rules/architecture.md | 202 ++++++++++++++++++ .../rules/blade-views.md | 36 ++++ .../laravel-best-practices/rules/caching.md | 70 ++++++ .../rules/collections.md | 44 ++++ .../laravel-best-practices/rules/config.md | 73 +++++++ .../rules/db-performance.md | 192 +++++++++++++++++ .../laravel-best-practices/rules/eloquent.md | 148 +++++++++++++ .../rules/error-handling.md | 72 +++++++ .../rules/events-notifications.md | 48 +++++ .../rules/http-client.md | 160 ++++++++++++++ .../laravel-best-practices/rules/mail.md | 27 +++ .../rules/migrations.md | 121 +++++++++++ .../rules/queue-jobs.md | 146 +++++++++++++ .../laravel-best-practices/rules/routing.md | 98 +++++++++ .../rules/scheduling.md | 39 ++++ .../laravel-best-practices/rules/security.md | 198 +++++++++++++++++ .../laravel-best-practices/rules/style.md | 125 +++++++++++ .../laravel-best-practices/rules/testing.md | 43 ++++ .../rules/validation.md | 75 +++++++ _api_app/.claude/skills/pest-testing/SKILL.md | 13 +- .../skills/laravel-best-practices/SKILL.md | 190 ++++++++++++++++ .../rules/advanced-queries.md | 106 +++++++++ .../rules/architecture.md | 202 ++++++++++++++++++ .../rules/blade-views.md | 36 ++++ .../laravel-best-practices/rules/caching.md | 70 ++++++ .../rules/collections.md | 44 ++++ .../laravel-best-practices/rules/config.md | 73 +++++++ .../rules/db-performance.md | 192 +++++++++++++++++ .../laravel-best-practices/rules/eloquent.md | 148 +++++++++++++ .../rules/error-handling.md | 72 +++++++ .../rules/events-notifications.md | 48 +++++ .../rules/http-client.md | 160 ++++++++++++++ .../laravel-best-practices/rules/mail.md | 27 +++ .../rules/migrations.md | 121 +++++++++++ .../rules/queue-jobs.md | 146 +++++++++++++ .../laravel-best-practices/rules/routing.md | 98 +++++++++ .../rules/scheduling.md | 39 ++++ .../laravel-best-practices/rules/security.md | 198 +++++++++++++++++ .../laravel-best-practices/rules/style.md | 125 +++++++++++ .../laravel-best-practices/rules/testing.md | 43 ++++ .../rules/validation.md | 75 +++++++ _api_app/.cursor/skills/pest-testing/SKILL.md | 13 +- .../skills/laravel-best-practices/SKILL.md | 190 ++++++++++++++++ .../rules/advanced-queries.md | 106 +++++++++ .../rules/architecture.md | 202 ++++++++++++++++++ .../rules/blade-views.md | 36 ++++ .../laravel-best-practices/rules/caching.md | 70 ++++++ .../rules/collections.md | 44 ++++ .../laravel-best-practices/rules/config.md | 73 +++++++ .../rules/db-performance.md | 192 +++++++++++++++++ .../laravel-best-practices/rules/eloquent.md | 148 +++++++++++++ .../rules/error-handling.md | 72 +++++++ .../rules/events-notifications.md | 48 +++++ .../rules/http-client.md | 160 ++++++++++++++ .../laravel-best-practices/rules/mail.md | 27 +++ .../rules/migrations.md | 121 +++++++++++ .../rules/queue-jobs.md | 146 +++++++++++++ .../laravel-best-practices/rules/routing.md | 98 +++++++++ .../rules/scheduling.md | 39 ++++ .../laravel-best-practices/rules/security.md | 198 +++++++++++++++++ .../laravel-best-practices/rules/style.md | 125 +++++++++++ .../laravel-best-practices/rules/testing.md | 43 ++++ .../rules/validation.md | 75 +++++++ _api_app/.github/skills/pest-testing/SKILL.md | 13 +- _api_app/AGENTS.md | 145 ++++--------- _api_app/CLAUDE.md | 145 ++++--------- _api_app/boost.json | 8 +- _api_app/composer.lock | 87 ++++---- 92 files changed, 9002 insertions(+), 287 deletions(-) create mode 100644 _api_app/.agents/skills/laravel-best-practices/SKILL.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/advanced-queries.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/architecture.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/blade-views.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/caching.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/collections.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/config.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/db-performance.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/eloquent.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/error-handling.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/events-notifications.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/http-client.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/mail.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/migrations.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/queue-jobs.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/routing.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/scheduling.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/security.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/style.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/testing.md create mode 100644 _api_app/.agents/skills/laravel-best-practices/rules/validation.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/SKILL.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/advanced-queries.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/architecture.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/blade-views.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/caching.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/collections.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/config.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/db-performance.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/eloquent.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/error-handling.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/events-notifications.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/http-client.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/mail.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/migrations.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/queue-jobs.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/routing.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/scheduling.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/security.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/style.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/testing.md create mode 100644 _api_app/.claude/skills/laravel-best-practices/rules/validation.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/SKILL.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/advanced-queries.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/architecture.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/blade-views.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/caching.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/collections.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/config.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/db-performance.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/eloquent.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/error-handling.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/events-notifications.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/http-client.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/mail.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/migrations.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/queue-jobs.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/routing.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/scheduling.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/security.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/style.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/testing.md create mode 100644 _api_app/.cursor/skills/laravel-best-practices/rules/validation.md create mode 100644 _api_app/.github/skills/laravel-best-practices/SKILL.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/advanced-queries.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/architecture.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/blade-views.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/caching.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/collections.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/config.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/db-performance.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/eloquent.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/error-handling.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/events-notifications.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/http-client.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/mail.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/migrations.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/queue-jobs.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/routing.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/scheduling.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/security.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/style.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/testing.md create mode 100644 _api_app/.github/skills/laravel-best-practices/rules/validation.md diff --git a/_api_app/.agents/skills/laravel-best-practices/SKILL.md b/_api_app/.agents/skills/laravel-best-practices/SKILL.md new file mode 100644 index 000000000..99018f3ae --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,190 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## Quick Reference + +### 1. Database Performance → `rules/db-performance.md` + +- Eager load with `with()` to prevent N+1 queries +- Enable `Model::preventLazyLoading()` in development +- Select only needed columns, avoid `SELECT *` +- `chunk()` / `chunkById()` for large datasets +- Index columns used in `WHERE`, `ORDER BY`, `JOIN` +- `withCount()` instead of loading relations to count +- `cursor()` for memory-efficient read-only iteration +- Never query in Blade templates + +### 2. Advanced Query Patterns → `rules/advanced-queries.md` + +- `addSelect()` subqueries over eager-loading entire has-many for a single value +- Dynamic relationships via subquery FK + `belongsTo` +- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries +- `setRelation()` to prevent circular N+1 queries +- `whereIn` + `pluck()` over `whereHas` for better index usage +- Two simple queries can beat one complex query +- Compound indexes matching `orderBy` column order +- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) + +### 3. Security → `rules/security.md` + +- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates +- No raw SQL with user input — use Eloquent or query builder +- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes +- Validate MIME type, extension, and size for file uploads +- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields + +### 4. Caching → `rules/caching.md` + +- `Cache::remember()` over manual get/put +- `Cache::flexible()` for stale-while-revalidate on high-traffic data +- `Cache::memo()` to avoid redundant cache hits within a request +- Cache tags to invalidate related groups +- `Cache::add()` for atomic conditional writes +- `once()` to memoize per-request or per-object lifetime +- `Cache::lock()` / `lockForUpdate()` for race conditions +- Failover cache stores in production + +### 5. Eloquent Patterns → `rules/eloquent.md` + +- Correct relationship types with return type hints +- Local scopes for reusable query constraints +- Global scopes sparingly — document their existence +- Attribute casts in the `casts()` method +- Cast date columns, use Carbon instances in templates +- `whereBelongsTo($model)` for cleaner queries +- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries + +### 6. Validation & Forms → `rules/validation.md` + +- Form Request classes, not inline validation +- Array notation `['required', 'email']` for new code; follow existing convention +- `$request->validated()` only — never `$request->all()` +- `Rule::when()` for conditional validation +- `after()` instead of `withValidator()` + +### 7. Configuration → `rules/config.md` + +- `env()` only inside config files +- `App::environment()` or `app()->isProduction()` +- Config, lang files, and constants over hardcoded text + +### 8. Testing Patterns → `rules/testing.md` + +- `LazilyRefreshDatabase` over `RefreshDatabase` for speed +- `assertModelExists()` over raw `assertDatabaseHas()` +- Factory states and sequences over manual overrides +- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before +- `recycle()` to share relationship instances across factories + +### 9. Queue & Job Patterns → `rules/queue-jobs.md` + +- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` +- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency +- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` +- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs +- Horizon for complex multi-queue scenarios + +### 10. Routing & Controllers → `rules/routing.md` + +- Implicit route model binding +- Scoped bindings for nested resources +- `Route::resource()` or `apiResource()` +- Methods under 10 lines — extract to actions/services +- Type-hint Form Requests for auto-validation + +### 11. HTTP Client → `rules/http-client.md` + +- Explicit `timeout` and `connectTimeout` on every request +- `retry()` with exponential backoff for external APIs +- Check response status or use `throw()` +- `Http::pool()` for concurrent independent requests +- `Http::fake()` and `preventStrayRequests()` in tests + +### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` + +- Event discovery over manual registration; `event:cache` in production +- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions +- Queue notifications and mailables with `ShouldQueue` +- On-demand notifications for non-user recipients +- `HasLocalePreference` on notifiable models +- `assertQueued()` not `assertSent()` for queued mailables +- Markdown mailables for transactional emails + +### 13. Error Handling → `rules/error-handling.md` + +- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern +- `ShouldntReport` for exceptions that should never log +- Throttle high-volume exceptions to protect log sinks +- `dontReportDuplicates()` for multi-catch scenarios +- Force JSON rendering for API routes +- Structured context via `context()` on exception classes + +### 14. Task Scheduling → `rules/scheduling.md` + +- `withoutOverlapping()` on variable-duration tasks +- `onOneServer()` on multi-server deployments +- `runInBackground()` for concurrent long tasks +- `environments()` to restrict to appropriate environments +- `takeUntilTimeout()` for time-bounded processing +- Schedule groups for shared configuration + +### 15. Architecture → `rules/architecture.md` + +- Single-purpose Action classes; dependency injection over `app()` helper +- Prefer official Laravel packages and follow conventions, don't override defaults +- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety +- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution + +### 16. Migrations → `rules/migrations.md` + +- Generate migrations with `php artisan make:migration` +- `constrained()` for foreign keys +- Never modify migrations that have run in production +- Add indexes in the migration, not as an afterthought +- Mirror column defaults in model `$attributes` +- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes +- One concern per migration — never mix DDL and DML + +### 17. Collections → `rules/collections.md` + +- Higher-order messages for simple collection operations +- `cursor()` vs. `lazy()` — choose based on relationship needs +- `lazyById()` when updating records while iterating +- `toQuery()` for bulk operations on collections + +### 18. Blade & Views → `rules/blade-views.md` + +- `$attributes->merge()` in component templates +- Blade components over `@include`; `@pushOnce` for per-component scripts +- View Composers for shared view data +- `@aware` for deeply nested component props + +### 19. Conventions & Style → `rules/style.md` + +- Follow Laravel naming conventions for all entities +- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions +- No JS/CSS in Blade, no HTML in PHP classes +- Code should be readable; comments only for config files + +## How to Apply + +Always use a sub-agent to read rule files and explore this skill's content. + +1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) +2. Check sibling files for existing patterns — follow those first per Consistency First +3. Verify API syntax with `search-docs` for the installed Laravel version \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/advanced-queries.md b/_api_app/.agents/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 000000000..920714a14 --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Patterns + +## Use `addSelect()` Subqueries for Single Values from Has-Many + +Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships via Subquery FK + +Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Use Conditional Aggregates Instead of Multiple Count Queries + +Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Use `setRelation()` to Prevent Circular N+1 + +When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Prefer `whereIn` + Subquery Over `whereHas` + +`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. + +Incorrect (correlated EXISTS re-executes per row): + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Correct (index-friendly subquery, no PHP memory overhead): + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Sometimes Two Simple Queries Beat One Complex Query + +Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. + +## Use Compound Indexes Matching `orderBy` Column Order + +When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query — column order must match the index +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Use Correlated Subqueries for Has-Many Ordering + +When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/architecture.md b/_api_app/.agents/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 000000000..165056422 --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,202 @@ +# Architecture Best Practices + +## Single-Purpose Action Classes + +Extract discrete business operations into invokable Action classes. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function execute(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Use Dependency Injection + +Always use constructor injection. Avoid `app()` or `resolve()` inside classes. + +Incorrect: +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Correct: +```php +class OrderController extends Controller +{ + public function __construct(private OrderService $service) {} + + public function store(StoreOrderRequest $request) + { + return $this->service->create($request->validated()); + } +} +``` + +## Code to Interfaces + +Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. + +Incorrect (concrete dependency): +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Correct (interface dependency): +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Default Sort by Descending + +When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres. + +Incorrect: +```php +$posts = Post::paginate(); +``` + +Correct: +```php +$posts = Post::latest()->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Prevent race conditions with `Cache::lock()` or `lockForUpdate()`. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level +$product = Product::where('id', $id)->lockForUpdate()->first(); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters. + +Incorrect: +```php +strlen('José'); // 5 (bytes, not characters) +strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte +``` + +Correct: +```php +mb_strlen('José'); // 4 (characters) +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead. + +Incorrect (job overhead for trivial work): +```php +dispatch(new LogPageView($page)); +``` + +Correct (runs after response, same process): +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work. + +## Use `Context` for Request-Scoped Data + +The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Anywhere later — controllers, jobs, log context +$tenantId = Context::get('tenant_id'); +``` + +Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations in parallel using child processes — no async libraries needed. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially. + +## Convention Over Configuration + +Follow Laravel conventions. Don't override defaults unnecessarily. + +Incorrect: +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Correct: +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/blade-views.md b/_api_app/.agents/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 000000000..c6f8aaf1e --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade & Views Best Practices + +## Use `$attributes->merge()` in Component Templates + +Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. + +## Prefer Blade Components Over `@include` + +`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. + +## Use View Composers for Shared View Data + +If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. + +## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) + +A single view can return either the full page or just a fragment, keeping routing clean. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Use `@aware` for Deeply Nested Component Props + +Avoids re-passing parent props through every level of nested components. \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/caching.md b/_api_app/.agents/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 000000000..eb3ef3e62 --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,70 @@ +# Caching Best Practices + +## Use `Cache::remember()` Instead of Manual Get/Put + +Atomic pattern prevents race conditions and removes boilerplate. + +Incorrect: +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Use `Cache::flexible()` for Stale-While-Revalidate + +On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background. + +Incorrect: `Cache::remember('users', 300, fn () => User::all());` + +Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function. + +## Use `Cache::memo()` to Avoid Redundant Hits Within a Request + +If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory. + +`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5. + +## Use Cache Tags to Invalidate Related Groups + +Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. + +Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` + +Correct: `Cache::add('lock', true, 10);` + +## Use `once()` for Per-Request Memoization + +`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. + +## Configure Failover Cache Stores in Production + +If Redis goes down, the app falls back to a secondary store automatically. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/collections.md b/_api_app/.agents/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 000000000..14f683d32 --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,44 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Incorrect: +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Correct: `$users->each->markAsVip();` + +Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. + +## Choose `cursor()` vs. `lazy()` Correctly + +- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). +- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. + +Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. + +Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. + +## Use `toQuery()` for Bulk Operations on Collections + +Avoids manual `whereIn` construction. + +Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` + +Correct: `$users->toQuery()->update([...]);` + +## Use `#[CollectedBy]` for Custom Collection Classes + +More declarative than overriding `newCollection()`. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/config.md b/_api_app/.agents/skills/laravel-best-practices/rules/config.md new file mode 100644 index 000000000..8fd8f536f --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,73 @@ +# Configuration Best Practices + +## `env()` Only in Config Files + +Direct `env()` calls return `null` when config is cached. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'key' => env('API_KEY'), + +// Application code +$key = config('services.key'); +``` + +## Use Encrypted Env or External Secrets + +Never store production secrets in plain `.env` files in version control. + +Incorrect: +```bash + +# .env committed to repo or shared in Slack + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Correct: +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: +```php +if (env('APP_ENV') === 'production') { +``` + +Correct: +```php +if (app()->isProduction()) { +// or +if (App::environment('production')) { +``` + +## Use Constants and Language Files + +Use class constants instead of hardcoded magic strings for model states, types, and statuses. + +```php +// Incorrect +return $this->type === 'normal'; + +// Correct +return $this->type === self::TYPE_NORMAL; +``` + +If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. + +```php +// Only when lang files already exist in the project +return back()->with('message', __('app.article_added')); +``` \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/db-performance.md b/_api_app/.agents/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 000000000..8fb719377 --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,192 @@ +# Database Performance Best Practices + +## Always Eager Load Relationships + +Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. + +Incorrect (N+1 — executes 1 + N queries): +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Correct (2 queries total): +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads to select only needed columns (always include the foreign key): + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. + +## Select Only Needed Columns + +Avoid `SELECT *` — especially when tables have large text or JSON columns. + +Incorrect: +```php +$posts = Post::with('author')->get(); +``` + +Correct: +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. + +## Chunk Large Datasets + +Never load thousands of records at once. Use chunking for batch processing. + +Incorrect: +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Correct: +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +## Add Database Indexes + +Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index()->constrained(); + $table->string('status')->index(); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). + +## Use `withCount()` for Counting Relations + +Never load entire collections just to count them. + +Incorrect: +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Correct: +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Use `cursor()` for Memory-Efficient Iteration + +For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. + +Incorrect: +```php +$users = User::where('active', true)->get(); +``` + +Correct: +```php +foreach (User::where('active', true)->cursor() as $user) { + ProcessUser::dispatch($user->id); +} +``` + +Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. + +## No Queries in Blade Templates + +Never execute queries in Blade templates. Pass data from controllers. + +Incorrect: +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Correct: +```php +// Controller +$users = User::with('profile')->get(); +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/eloquent.md b/_api_app/.agents/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 000000000..09cd66a05 --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,148 @@ +# Eloquent Best Practices + +## Use Correct Relationship Types + +Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Incorrect: +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Correct: +```php +public function scopeActive(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Incorrect (global scope for a conditional filter): +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} +// Now admin panels, reports, and background jobs all silently skip drafts +``` + +Correct (local scope you opt into): +```php +public function scopePublished(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date Columns Properly + +Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. + +Incorrect: +```blade +{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} +``` + +Correct: +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +Cleaner than manually specifying foreign keys. + +Incorrect: +```php +Post::where('user_id', $user->id)->get(); +``` + +Correct: +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Avoid Hardcoded Table Names in Queries + +Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). + +Incorrect: +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Correct — reference the model's table: +```php +DB::table((new User)->getTable())->where('active', true)->get(); + +// Even better — use Eloquent or the query builder instead of raw SQL +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. + +**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/error-handling.md b/_api_app/.agents/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 000000000..bb8e7a387 --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,72 @@ +# Error Handling Best Practices + +## Exception Reporting and Rendering + +There are two valid approaches — choose one and apply it consistently across the project. + +**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void { /* custom reporting */ } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +Check the existing codebase and follow whichever pattern is already established. + +## Use `ShouldntReport` for Exceptions That Should Never Log + +More discoverable than listing classes in `dontReport()`. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exceptions + +A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. + +## Enable `dontReportDuplicates()` + +Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. + +## Force JSON Error Rendering for API Routes + +Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/events-notifications.md b/_api_app/.agents/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 000000000..bc43f1997 --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,48 @@ +# Events & Notifications Best Practices + +## Rely on Event Discovery + +Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. + +## Run `event:cache` in Production Deploy + +Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Always Queue Notifications + +Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Use `afterCommit()` on Notifications in Transactions + +Same race condition as events — the queued notification job may run before the transaction commits. + +## Route Notification Channels to Dedicated Queues + +Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/http-client.md b/_api_app/.agents/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 000000000..0a7876ed3 --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,160 @@ +# HTTP Client Best Practices + +## Always Set Explicit Timeouts + +The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users'); +``` + +Correct: +```php +$response = Http::timeout(5) + ->connectTimeout(3) + ->get('https://api.example.com/users'); +``` + +For service-specific clients, define timeouts in a macro: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->timeout(10) + ->connectTimeout(3) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Use Retry with Backoff for External APIs + +External APIs have transient failures. Use `retry()` with increasing delays. + +Incorrect: +```php +$response = Http::post('https://api.stripe.com/v1/charges', $data); + +if ($response->failed()) { + throw new PaymentFailedException('Charge failed'); +} +``` + +Correct: +```php +$response = Http::retry([100, 500, 1000]) + ->timeout(10) + ->post('https://api.stripe.com/v1/charges', $data); +``` + +Only retry on specific errors: + +```php +$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()); +})->post('https://api.example.com/data'); +``` + +## Handle Errors Explicitly + +The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users/1'); +$user = $response->json(); // Could be an error body +``` + +Correct: +```php +$response = Http::timeout(5) + ->get('https://api.example.com/users/1') + ->throw(); + +$user = $response->json(); +``` + +For graceful degradation: + +```php +$response = Http::get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Use Request Pooling for Concurrent Requests + +When making multiple independent API calls, use `Http::pool()` instead of sequential calls. + +Incorrect: +```php +$users = Http::get('https://api.example.com/users')->json(); +$posts = Http::get('https://api.example.com/posts')->json(); +$comments = Http::get('https://api.example.com/comments')->json(); +``` + +Correct: +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->get('https://api.example.com/users'), + $pool->as('posts')->get('https://api.example.com/posts'), + $pool->as('comments')->get('https://api.example.com/comments'), +]); + +$users = $responses['users']->json(); +$posts = $responses['posts']->json(); +``` + +## Fake HTTP Calls in Tests + +Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. + +Incorrect: +```php +it('syncs user from API', function () { + $service = new UserSyncService; + $service->sync(1); // Hits the real API +}); +``` + +Correct: +```php +it('syncs user from API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + $service = new UserSyncService; + $service->sync(1); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.example.com/users/1'; + }); +}); +``` + +Test failure scenarios too: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/mail.md b/_api_app/.agents/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 000000000..c7f67966e --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,27 @@ +# Mail Best Practices + +## Implement `ShouldQueue` on the Mailable Class + +Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. + +## Use `afterCommit()` on Mailables Inside Transactions + +A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. + +## Use `assertQueued()` Not `assertSent()` for Queued Mailables + +`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence. + +Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. + +Correct: `Mail::assertQueued(OrderShipped::class);` + +## Use Markdown Mailables for Transactional Emails + +Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. + +## Separate Content Tests from Sending Tests + +Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. +Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. +Don't mix them — it conflates concerns and makes tests brittle. \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/migrations.md b/_api_app/.agents/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 000000000..de25aa39c --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,121 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Always use `php artisan make:migration` for consistent naming and timestamps. + +Incorrect (manually created file): +```php +// database/migrations/posts_migration.php ← wrong naming, no timestamp +``` + +Correct (Artisan-generated): +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Use `constrained()` for Foreign Keys + +Automatic naming and referential integrity. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); + +// Non-standard names +$table->foreignId('author_id')->constrained('users'); +``` + +## Never Modify Deployed Migrations + +Once a migration has run in production, treat it as immutable. Create a new migration to change the table. + +Incorrect (editing a deployed migration): +```php +// 2024_01_01_create_posts_table.php — already in production +$table->string('slug')->unique(); // ← added after deployment +``` + +Correct (new migration to alter): +```php +// 2024_03_15_add_slug_to_posts_table.php +Schema::table('posts', function (Blueprint $table) { + $table->string('slug')->unique()->after('title'); +}); +``` + +## Add Indexes in the Migration + +Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->index(); + $table->string('status')->index(); + $table->timestamp('shipped_at')->nullable()->index(); + $table->timestamps(); +}); +``` + +## Mirror Defaults in Model `$attributes` + +When a column has a database default, mirror it in the model so new instances have correct values before saving. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Write Reversible `down()` Methods by Default + +Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. + +```php +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('slug'); + }); +} +``` + +For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. + +## Keep Migrations Focused + +One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). + +Incorrect (partial failure creates unrecoverable state): +```php +public function up(): void +{ + Schema::create('settings', function (Blueprint $table) { ... }); + DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +} +``` + +Correct (separate migrations): +```php +// Migration 1: create_settings_table +Schema::create('settings', function (Blueprint $table) { ... }); + +// Migration 2: seed_default_settings +DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +``` \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/queue-jobs.md b/_api_app/.agents/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 000000000..2f174dfc2 --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,146 @@ +# Queue & Job Best Practices + +## Set `retry_after` Greater Than `timeout` + +If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. + +Incorrect (`retry_after` ≤ `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 90 ← job retried while still running! +``` + +Correct (`retry_after` > `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 180 ← safely longer than any job timeout +``` + +## Use Exponential Backoff + +Use progressively longer delays between retries to avoid hammering failing services. + +Incorrect (fixed retry interval): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + // Default: retries immediately, overwhelming the API +} +``` + +Correct (exponential backoff): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + public $backoff = [1, 5, 10]; +} +``` + +## Implement `ShouldBeUnique` + +Prevent duplicate job processing. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public function uniqueId(): string + { + return $this->order->id; + } + + public $uniqueFor = 3600; +} +``` + +## Always Implement `failed()` + +Handle errors explicitly — don't rely on silent failure. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); +} +``` + +## Rate Limit External API Calls in Jobs + +Use `RateLimited` middleware to throttle jobs calling third-party APIs. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Related Jobs + +Use `Bus::batch()` when jobs should succeed or fail together. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) +->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) +->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed')) +->dispatch(); +``` + +## `retryUntil()` Needs `$tries = 0` + +When using time-based retry limits, set `$tries = 0` to avoid premature failure. + +```php +public $tries = 0; + +public function retryUntil(): \DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use `WithoutOverlapping::untilProcessing()` + +Prevents concurrent execution while allowing new instances to queue. + +```php +public function middleware(): array +{ + return [new WithoutOverlapping($this->product->id)->untilProcessing()]; +} +``` + +Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts. + +## Use Horizon for Complex Queue Scenarios + +Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['high', 'default', 'low'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], + ], +], +``` \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/routing.md b/_api_app/.agents/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 000000000..e288375d7 --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,98 @@ +# Routing & Controllers Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models automatically from route parameters. + +Incorrect: +```php +public function show(int $id) +{ + $post = Post::findOrFail($id); +} +``` + +Correct: +```php +public function show(Post $post) +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Use Scoped Bindings for Nested Resources + +Enforce parent-child relationships automatically. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // $post is automatically scoped to $user +})->scopeBindings(); +``` + +## Use Resource Controllers + +Use `Route::resource()` or `apiResource()` for RESTful endpoints. + +```php +Route::resource('posts', PostController::class); +Route::apiResource('api/posts', Api\PostController::class); +``` + +## Keep Controllers Thin + +Aim for under 10 lines per method. Extract business logic to action or service classes. + +Incorrect: +```php +public function store(Request $request) +{ + $validated = $request->validate([...]); + if ($request->hasFile('image')) { + $request->file('image')->move(public_path('images')); + } + $post = Post::create($validated); + $post->tags()->sync($validated['tags']); + event(new PostCreated($post)); + return redirect()->route('posts.show', $post); +} +``` + +Correct: +```php +public function store(StorePostRequest $request, CreatePostAction $create) +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +## Type-Hint Form Requests + +Type-hinting Form Requests triggers automatic validation and authorization before the method executes. + +Incorrect: +```php +public function store(Request $request): RedirectResponse +{ + $validated = $request->validate([ + 'title' => ['required', 'max:255'], + 'body' => ['required'], + ]); + + Post::create($validated); + + return redirect()->route('posts.index'); +} +``` + +Correct: +```php +public function store(StorePostRequest $request): RedirectResponse +{ + Post::create($request->validated()); + + return redirect()->route('posts.index'); +} +``` \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/scheduling.md b/_api_app/.agents/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 000000000..dfaefa26f --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,39 @@ +# Task Scheduling Best Practices + +## Use `withoutOverlapping()` on Variable-Duration Tasks + +Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. + +## Use `onOneServer()` on Multi-Server Deployments + +Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). + +## Use `runInBackground()` for Concurrent Long Tasks + +By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. + +## Use `environments()` to Restrict Tasks + +Prevent accidental execution of production-only tasks (billing, reporting) on staging. + +```php +Schedule::command('billing:charge')->monthly()->environments(['production']); +``` + +## Use `takeUntilTimeout()` for Time-Bounded Processing + +A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. + +## Use Schedule Groups for Shared Configuration + +Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/security.md b/_api_app/.agents/skills/laravel-best-practices/rules/security.md new file mode 100644 index 000000000..524d47e61 --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,198 @@ +# Security Best Practices + +## Mass Assignment Protection + +Every model must define `$fillable` (whitelist) or `$guarded` (blacklist). + +Incorrect: +```php +class User extends Model +{ + protected $guarded = []; // All fields are mass assignable +} +``` + +Correct: +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Never use `$guarded = []` on models that accept user input. + +## Authorize Every Action + +Use policies or gates in controllers. Never skip authorization. + +Incorrect: +```php +public function update(Request $request, Post $post) +{ + $post->update($request->validated()); +} +``` + +Correct: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); +} +``` + +Or via Form Request: + +```php +public function authorize(): bool +{ + return $this->user()->can('update', $this->route('post')); +} +``` + +## Prevent SQL Injection + +Always use parameter binding. Never interpolate user input into queries. + +Incorrect: +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: +```php +User::where('name', $request->name)->get(); + +// Raw expressions with bindings +User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get(); +``` + +## Escape Output to Prevent XSS + +Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content. + +Incorrect: +```blade +{!! $user->bio !!} +``` + +Correct: +```blade +{{ $user->bio }} +``` + +## CSRF Protection + +Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia. + +Incorrect: +```blade +
+ +
+``` + +Correct: +```blade +
+ @csrf + +
+``` + +## Rate Limit Auth and API Routes + +Apply `throttle` middleware to authentication and API routes. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +## Validate File Uploads + +Validate MIME type, extension, and size. Never trust client-provided filenames. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Store with generated filenames: + +```php +$path = $request->file('avatar')->store('avatars', 'public'); +``` + +## Keep Secrets Out of Code + +Never commit `.env`. Access secrets via `config()` only. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'api_key' => env('API_KEY'), + +// In application code +$key = config('services.api_key'); +``` + +## Audit Dependencies + +Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. + +```bash +composer audit +``` + +## Encrypt Sensitive Database Fields + +Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. + +Incorrect: +```php +class Integration extends Model +{ + protected function casts(): array + { + return [ + 'api_key' => 'string', + ]; + } +} +``` + +Correct: +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/style.md b/_api_app/.agents/skills/laravel-best-practices/rules/style.md new file mode 100644 index 000000000..67af98919 --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,125 @@ +# Conventions & Style + +## Follow Laravel Naming Conventions + +| What | Convention | Good | Bad | +|------|-----------|------|-----| +| Controller | singular | `ArticleController` | `ArticlesController` | +| Model | singular | `User` | `Users` | +| Table | plural, snake_case | `article_comments` | `articleComments` | +| Pivot table | singular alphabetical | `article_user` | `user_article` | +| Column | snake_case, no model name | `meta_title` | `article_meta_title` | +| Foreign key | singular model + `_id` | `article_id` | `articles_id` | +| Route | plural | `articles/1` | `article/1` | +| Route name | snake_case with dots | `users.show_active` | `users.show-active` | +| Method | camelCase | `getAll` | `get_all` | +| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` | +| Collection | descriptive, plural | `$activeUsers` | `$data` | +| Object | descriptive, singular | `$activeUser` | `$users` | +| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` | +| Config | snake_case | `google_calendar.php` | `googleCalendar.php` | +| Enum | singular | `UserType` | `UserTypes` | + +## Prefer Shorter Readable Syntax + +| Verbose | Shorter | +|---------|---------| +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `$request->input('name')` | `$request->name` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `App::make('Class')` | `app('Class')` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()->name` | `->value('name')` | + +## Use Laravel String & Array Helpers + +Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them. + +Strings — use `Str` and fluent `Str::of()` over raw PHP: +```php +// Incorrect +$slug = strtolower(str_replace(' ', '-', $title)); +$short = substr($text, 0, 100) . '...'; +$class = substr(strrchr('App\Models\User', '\'), 1); + +// Correct +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename('App\Models\User'); +``` + +Fluent strings — chain operations for complex transformations: +```php +// Incorrect +$result = strtolower(trim(str_replace('_', '-', $input))); + +// Correct +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`. + +Arrays — use `Arr` over raw PHP: +```php +// Incorrect +$name = isset($array['user']['name']) ? $array['user']['name'] : 'default'; + +// Correct +$name = Arr::get($array, 'user.name', 'default'); +``` + +Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`. + +Numbers — use `Number` for display formatting: +```php +Number::format(1000000); // "1,000,000" +Number::currency(1500, 'USD'); // "$1,500.00" +Number::abbreviate(1000000); // "1M" +Number::fileSize(1024 * 1024); // "1 MB" +Number::percentage(75.5); // "75.5%" +``` + +URIs — use `Uri` for URL manipulation: +```php +$uri = Uri::of('https://example.com/search') + ->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining. + +Use `search-docs` for the full list of available methods — these helpers are extensive. + +## No Inline JS/CSS in Blade + +Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes. + +Incorrect: +```blade +let article = `{{ json_encode($article) }}`; +``` + +Correct: +```blade + +``` + +Pass data to JS via data attributes or use a dedicated PHP-to-JS package. + +## No Unnecessary Comments + +Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected. + +Incorrect: +```php +// Check if there are any joins +if (count((array) $builder->getQuery()->joins) > 0) +``` + +Correct: +```php +if ($this->hasJoins()) +``` \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/testing.md b/_api_app/.agents/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 000000000..d39cc3ed0 --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/testing.md @@ -0,0 +1,43 @@ +# Testing Best Practices + +## Use `LazilyRefreshDatabase` Over `RefreshDatabase` + +`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites. + +## Use Model Assertions Over Raw Database Assertions + +Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` + +Correct: `$this->assertModelExists($user);` + +More expressive, type-safe, and fails with clearer messages. + +## Use Factory States and Sequences + +Named states make tests self-documenting. Sequences eliminate repetitive setup. + +Incorrect: `User::factory()->create(['email_verified_at' => null]);` + +Correct: `User::factory()->unverified()->create();` + +## Use `Exceptions::fake()` to Assert Exception Reporting + +Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. + +## Call `Event::fake()` After Factory Setup + +Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. + +Incorrect: `Event::fake(); $user = User::factory()->create();` + +Correct: `$user = User::factory()->create(); Event::fake();` + +## Use `recycle()` to Share Relationship Instances Across Factories + +Without `recycle()`, nested factories create separate instances of the same conceptual entity. + +```php +Ticket::factory() + ->recycle(Airline::factory()->create()) + ->create(); +``` \ No newline at end of file diff --git a/_api_app/.agents/skills/laravel-best-practices/rules/validation.md b/_api_app/.agents/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 000000000..a20202ff1 --- /dev/null +++ b/_api_app/.agents/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,75 @@ +# Validation & Forms Best Practices + +## Use Form Request Classes + +Extract validation from controllers into dedicated Form Request classes. + +Incorrect: +```php +public function store(Request $request) +{ + $request->validate([ + 'title' => 'required|max:255', + 'body' => 'required', + ]); +} +``` + +Correct: +```php +public function store(StorePostRequest $request) +{ + Post::create($request->validated()); +} +``` + +## Array vs. String Notation for Rules + +Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. + +```php +// Preferred for new code +'email' => ['required', 'email', Rule::unique('users')], + +// Follow existing convention if the project uses string notation +'email' => 'required|email|unique:users', +``` + +## Always Use `validated()` + +Get only validated data. Never use `$request->all()` for mass operations. + +Incorrect: +```php +Post::create($request->all()); +``` + +Correct: +```php +Post::create($request->validated()); +``` + +## Use `Rule::when()` for Conditional Validation + +```php +'company_name' => [ + Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), +], +``` + +## Use the `after()` Method for Custom Validation + +Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($this->quantity > Product::find($this->product_id)?->stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` \ No newline at end of file diff --git a/_api_app/.agents/skills/pest-testing/SKILL.md b/_api_app/.agents/skills/pest-testing/SKILL.md index f6973277f..0761b6e76 100644 --- a/_api_app/.agents/skills/pest-testing/SKILL.md +++ b/_api_app/.agents/skills/pest-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: pest-testing -description: "Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works." +description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit) or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 3 features. Do not use for editing factories, seeders, migrations, controllers, models, or non-test PHP code." license: MIT metadata: author: laravel @@ -8,15 +8,6 @@ metadata: # Pest Testing 3 -## When to Apply - -Activate this skill when: -- Creating new tests (unit or feature) -- Modifying existing tests -- Debugging test failures -- Working with datasets, mocking, or test organization -- Writing architecture tests - ## Documentation Use `search-docs` for detailed Pest 3 patterns and documentation. @@ -35,6 +26,8 @@ All tests must be written using Pest. Use `php artisan make:test --pest {name}`. ### Basic Test Structure +Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`. + ```php it('is true', function () { diff --git a/_api_app/.claude/skills/laravel-best-practices/SKILL.md b/_api_app/.claude/skills/laravel-best-practices/SKILL.md new file mode 100644 index 000000000..99018f3ae --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,190 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## Quick Reference + +### 1. Database Performance → `rules/db-performance.md` + +- Eager load with `with()` to prevent N+1 queries +- Enable `Model::preventLazyLoading()` in development +- Select only needed columns, avoid `SELECT *` +- `chunk()` / `chunkById()` for large datasets +- Index columns used in `WHERE`, `ORDER BY`, `JOIN` +- `withCount()` instead of loading relations to count +- `cursor()` for memory-efficient read-only iteration +- Never query in Blade templates + +### 2. Advanced Query Patterns → `rules/advanced-queries.md` + +- `addSelect()` subqueries over eager-loading entire has-many for a single value +- Dynamic relationships via subquery FK + `belongsTo` +- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries +- `setRelation()` to prevent circular N+1 queries +- `whereIn` + `pluck()` over `whereHas` for better index usage +- Two simple queries can beat one complex query +- Compound indexes matching `orderBy` column order +- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) + +### 3. Security → `rules/security.md` + +- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates +- No raw SQL with user input — use Eloquent or query builder +- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes +- Validate MIME type, extension, and size for file uploads +- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields + +### 4. Caching → `rules/caching.md` + +- `Cache::remember()` over manual get/put +- `Cache::flexible()` for stale-while-revalidate on high-traffic data +- `Cache::memo()` to avoid redundant cache hits within a request +- Cache tags to invalidate related groups +- `Cache::add()` for atomic conditional writes +- `once()` to memoize per-request or per-object lifetime +- `Cache::lock()` / `lockForUpdate()` for race conditions +- Failover cache stores in production + +### 5. Eloquent Patterns → `rules/eloquent.md` + +- Correct relationship types with return type hints +- Local scopes for reusable query constraints +- Global scopes sparingly — document their existence +- Attribute casts in the `casts()` method +- Cast date columns, use Carbon instances in templates +- `whereBelongsTo($model)` for cleaner queries +- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries + +### 6. Validation & Forms → `rules/validation.md` + +- Form Request classes, not inline validation +- Array notation `['required', 'email']` for new code; follow existing convention +- `$request->validated()` only — never `$request->all()` +- `Rule::when()` for conditional validation +- `after()` instead of `withValidator()` + +### 7. Configuration → `rules/config.md` + +- `env()` only inside config files +- `App::environment()` or `app()->isProduction()` +- Config, lang files, and constants over hardcoded text + +### 8. Testing Patterns → `rules/testing.md` + +- `LazilyRefreshDatabase` over `RefreshDatabase` for speed +- `assertModelExists()` over raw `assertDatabaseHas()` +- Factory states and sequences over manual overrides +- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before +- `recycle()` to share relationship instances across factories + +### 9. Queue & Job Patterns → `rules/queue-jobs.md` + +- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` +- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency +- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` +- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs +- Horizon for complex multi-queue scenarios + +### 10. Routing & Controllers → `rules/routing.md` + +- Implicit route model binding +- Scoped bindings for nested resources +- `Route::resource()` or `apiResource()` +- Methods under 10 lines — extract to actions/services +- Type-hint Form Requests for auto-validation + +### 11. HTTP Client → `rules/http-client.md` + +- Explicit `timeout` and `connectTimeout` on every request +- `retry()` with exponential backoff for external APIs +- Check response status or use `throw()` +- `Http::pool()` for concurrent independent requests +- `Http::fake()` and `preventStrayRequests()` in tests + +### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` + +- Event discovery over manual registration; `event:cache` in production +- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions +- Queue notifications and mailables with `ShouldQueue` +- On-demand notifications for non-user recipients +- `HasLocalePreference` on notifiable models +- `assertQueued()` not `assertSent()` for queued mailables +- Markdown mailables for transactional emails + +### 13. Error Handling → `rules/error-handling.md` + +- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern +- `ShouldntReport` for exceptions that should never log +- Throttle high-volume exceptions to protect log sinks +- `dontReportDuplicates()` for multi-catch scenarios +- Force JSON rendering for API routes +- Structured context via `context()` on exception classes + +### 14. Task Scheduling → `rules/scheduling.md` + +- `withoutOverlapping()` on variable-duration tasks +- `onOneServer()` on multi-server deployments +- `runInBackground()` for concurrent long tasks +- `environments()` to restrict to appropriate environments +- `takeUntilTimeout()` for time-bounded processing +- Schedule groups for shared configuration + +### 15. Architecture → `rules/architecture.md` + +- Single-purpose Action classes; dependency injection over `app()` helper +- Prefer official Laravel packages and follow conventions, don't override defaults +- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety +- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution + +### 16. Migrations → `rules/migrations.md` + +- Generate migrations with `php artisan make:migration` +- `constrained()` for foreign keys +- Never modify migrations that have run in production +- Add indexes in the migration, not as an afterthought +- Mirror column defaults in model `$attributes` +- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes +- One concern per migration — never mix DDL and DML + +### 17. Collections → `rules/collections.md` + +- Higher-order messages for simple collection operations +- `cursor()` vs. `lazy()` — choose based on relationship needs +- `lazyById()` when updating records while iterating +- `toQuery()` for bulk operations on collections + +### 18. Blade & Views → `rules/blade-views.md` + +- `$attributes->merge()` in component templates +- Blade components over `@include`; `@pushOnce` for per-component scripts +- View Composers for shared view data +- `@aware` for deeply nested component props + +### 19. Conventions & Style → `rules/style.md` + +- Follow Laravel naming conventions for all entities +- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions +- No JS/CSS in Blade, no HTML in PHP classes +- Code should be readable; comments only for config files + +## How to Apply + +Always use a sub-agent to read rule files and explore this skill's content. + +1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) +2. Check sibling files for existing patterns — follow those first per Consistency First +3. Verify API syntax with `search-docs` for the installed Laravel version \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/advanced-queries.md b/_api_app/.claude/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 000000000..920714a14 --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Patterns + +## Use `addSelect()` Subqueries for Single Values from Has-Many + +Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships via Subquery FK + +Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Use Conditional Aggregates Instead of Multiple Count Queries + +Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Use `setRelation()` to Prevent Circular N+1 + +When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Prefer `whereIn` + Subquery Over `whereHas` + +`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. + +Incorrect (correlated EXISTS re-executes per row): + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Correct (index-friendly subquery, no PHP memory overhead): + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Sometimes Two Simple Queries Beat One Complex Query + +Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. + +## Use Compound Indexes Matching `orderBy` Column Order + +When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query — column order must match the index +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Use Correlated Subqueries for Has-Many Ordering + +When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/architecture.md b/_api_app/.claude/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 000000000..165056422 --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,202 @@ +# Architecture Best Practices + +## Single-Purpose Action Classes + +Extract discrete business operations into invokable Action classes. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function execute(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Use Dependency Injection + +Always use constructor injection. Avoid `app()` or `resolve()` inside classes. + +Incorrect: +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Correct: +```php +class OrderController extends Controller +{ + public function __construct(private OrderService $service) {} + + public function store(StoreOrderRequest $request) + { + return $this->service->create($request->validated()); + } +} +``` + +## Code to Interfaces + +Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. + +Incorrect (concrete dependency): +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Correct (interface dependency): +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Default Sort by Descending + +When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres. + +Incorrect: +```php +$posts = Post::paginate(); +``` + +Correct: +```php +$posts = Post::latest()->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Prevent race conditions with `Cache::lock()` or `lockForUpdate()`. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level +$product = Product::where('id', $id)->lockForUpdate()->first(); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters. + +Incorrect: +```php +strlen('José'); // 5 (bytes, not characters) +strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte +``` + +Correct: +```php +mb_strlen('José'); // 4 (characters) +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead. + +Incorrect (job overhead for trivial work): +```php +dispatch(new LogPageView($page)); +``` + +Correct (runs after response, same process): +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work. + +## Use `Context` for Request-Scoped Data + +The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Anywhere later — controllers, jobs, log context +$tenantId = Context::get('tenant_id'); +``` + +Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations in parallel using child processes — no async libraries needed. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially. + +## Convention Over Configuration + +Follow Laravel conventions. Don't override defaults unnecessarily. + +Incorrect: +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Correct: +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/blade-views.md b/_api_app/.claude/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 000000000..c6f8aaf1e --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade & Views Best Practices + +## Use `$attributes->merge()` in Component Templates + +Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. + +## Prefer Blade Components Over `@include` + +`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. + +## Use View Composers for Shared View Data + +If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. + +## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) + +A single view can return either the full page or just a fragment, keeping routing clean. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Use `@aware` for Deeply Nested Component Props + +Avoids re-passing parent props through every level of nested components. \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/caching.md b/_api_app/.claude/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 000000000..eb3ef3e62 --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,70 @@ +# Caching Best Practices + +## Use `Cache::remember()` Instead of Manual Get/Put + +Atomic pattern prevents race conditions and removes boilerplate. + +Incorrect: +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Use `Cache::flexible()` for Stale-While-Revalidate + +On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background. + +Incorrect: `Cache::remember('users', 300, fn () => User::all());` + +Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function. + +## Use `Cache::memo()` to Avoid Redundant Hits Within a Request + +If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory. + +`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5. + +## Use Cache Tags to Invalidate Related Groups + +Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. + +Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` + +Correct: `Cache::add('lock', true, 10);` + +## Use `once()` for Per-Request Memoization + +`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. + +## Configure Failover Cache Stores in Production + +If Redis goes down, the app falls back to a secondary store automatically. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/collections.md b/_api_app/.claude/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 000000000..14f683d32 --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,44 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Incorrect: +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Correct: `$users->each->markAsVip();` + +Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. + +## Choose `cursor()` vs. `lazy()` Correctly + +- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). +- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. + +Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. + +Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. + +## Use `toQuery()` for Bulk Operations on Collections + +Avoids manual `whereIn` construction. + +Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` + +Correct: `$users->toQuery()->update([...]);` + +## Use `#[CollectedBy]` for Custom Collection Classes + +More declarative than overriding `newCollection()`. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/config.md b/_api_app/.claude/skills/laravel-best-practices/rules/config.md new file mode 100644 index 000000000..8fd8f536f --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,73 @@ +# Configuration Best Practices + +## `env()` Only in Config Files + +Direct `env()` calls return `null` when config is cached. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'key' => env('API_KEY'), + +// Application code +$key = config('services.key'); +``` + +## Use Encrypted Env or External Secrets + +Never store production secrets in plain `.env` files in version control. + +Incorrect: +```bash + +# .env committed to repo or shared in Slack + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Correct: +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: +```php +if (env('APP_ENV') === 'production') { +``` + +Correct: +```php +if (app()->isProduction()) { +// or +if (App::environment('production')) { +``` + +## Use Constants and Language Files + +Use class constants instead of hardcoded magic strings for model states, types, and statuses. + +```php +// Incorrect +return $this->type === 'normal'; + +// Correct +return $this->type === self::TYPE_NORMAL; +``` + +If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. + +```php +// Only when lang files already exist in the project +return back()->with('message', __('app.article_added')); +``` \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/db-performance.md b/_api_app/.claude/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 000000000..8fb719377 --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,192 @@ +# Database Performance Best Practices + +## Always Eager Load Relationships + +Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. + +Incorrect (N+1 — executes 1 + N queries): +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Correct (2 queries total): +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads to select only needed columns (always include the foreign key): + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. + +## Select Only Needed Columns + +Avoid `SELECT *` — especially when tables have large text or JSON columns. + +Incorrect: +```php +$posts = Post::with('author')->get(); +``` + +Correct: +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. + +## Chunk Large Datasets + +Never load thousands of records at once. Use chunking for batch processing. + +Incorrect: +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Correct: +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +## Add Database Indexes + +Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index()->constrained(); + $table->string('status')->index(); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). + +## Use `withCount()` for Counting Relations + +Never load entire collections just to count them. + +Incorrect: +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Correct: +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Use `cursor()` for Memory-Efficient Iteration + +For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. + +Incorrect: +```php +$users = User::where('active', true)->get(); +``` + +Correct: +```php +foreach (User::where('active', true)->cursor() as $user) { + ProcessUser::dispatch($user->id); +} +``` + +Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. + +## No Queries in Blade Templates + +Never execute queries in Blade templates. Pass data from controllers. + +Incorrect: +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Correct: +```php +// Controller +$users = User::with('profile')->get(); +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/eloquent.md b/_api_app/.claude/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 000000000..09cd66a05 --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,148 @@ +# Eloquent Best Practices + +## Use Correct Relationship Types + +Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Incorrect: +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Correct: +```php +public function scopeActive(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Incorrect (global scope for a conditional filter): +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} +// Now admin panels, reports, and background jobs all silently skip drafts +``` + +Correct (local scope you opt into): +```php +public function scopePublished(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date Columns Properly + +Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. + +Incorrect: +```blade +{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} +``` + +Correct: +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +Cleaner than manually specifying foreign keys. + +Incorrect: +```php +Post::where('user_id', $user->id)->get(); +``` + +Correct: +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Avoid Hardcoded Table Names in Queries + +Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). + +Incorrect: +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Correct — reference the model's table: +```php +DB::table((new User)->getTable())->where('active', true)->get(); + +// Even better — use Eloquent or the query builder instead of raw SQL +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. + +**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/error-handling.md b/_api_app/.claude/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 000000000..bb8e7a387 --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,72 @@ +# Error Handling Best Practices + +## Exception Reporting and Rendering + +There are two valid approaches — choose one and apply it consistently across the project. + +**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void { /* custom reporting */ } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +Check the existing codebase and follow whichever pattern is already established. + +## Use `ShouldntReport` for Exceptions That Should Never Log + +More discoverable than listing classes in `dontReport()`. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exceptions + +A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. + +## Enable `dontReportDuplicates()` + +Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. + +## Force JSON Error Rendering for API Routes + +Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/events-notifications.md b/_api_app/.claude/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 000000000..bc43f1997 --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,48 @@ +# Events & Notifications Best Practices + +## Rely on Event Discovery + +Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. + +## Run `event:cache` in Production Deploy + +Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Always Queue Notifications + +Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Use `afterCommit()` on Notifications in Transactions + +Same race condition as events — the queued notification job may run before the transaction commits. + +## Route Notification Channels to Dedicated Queues + +Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/http-client.md b/_api_app/.claude/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 000000000..0a7876ed3 --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,160 @@ +# HTTP Client Best Practices + +## Always Set Explicit Timeouts + +The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users'); +``` + +Correct: +```php +$response = Http::timeout(5) + ->connectTimeout(3) + ->get('https://api.example.com/users'); +``` + +For service-specific clients, define timeouts in a macro: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->timeout(10) + ->connectTimeout(3) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Use Retry with Backoff for External APIs + +External APIs have transient failures. Use `retry()` with increasing delays. + +Incorrect: +```php +$response = Http::post('https://api.stripe.com/v1/charges', $data); + +if ($response->failed()) { + throw new PaymentFailedException('Charge failed'); +} +``` + +Correct: +```php +$response = Http::retry([100, 500, 1000]) + ->timeout(10) + ->post('https://api.stripe.com/v1/charges', $data); +``` + +Only retry on specific errors: + +```php +$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()); +})->post('https://api.example.com/data'); +``` + +## Handle Errors Explicitly + +The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users/1'); +$user = $response->json(); // Could be an error body +``` + +Correct: +```php +$response = Http::timeout(5) + ->get('https://api.example.com/users/1') + ->throw(); + +$user = $response->json(); +``` + +For graceful degradation: + +```php +$response = Http::get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Use Request Pooling for Concurrent Requests + +When making multiple independent API calls, use `Http::pool()` instead of sequential calls. + +Incorrect: +```php +$users = Http::get('https://api.example.com/users')->json(); +$posts = Http::get('https://api.example.com/posts')->json(); +$comments = Http::get('https://api.example.com/comments')->json(); +``` + +Correct: +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->get('https://api.example.com/users'), + $pool->as('posts')->get('https://api.example.com/posts'), + $pool->as('comments')->get('https://api.example.com/comments'), +]); + +$users = $responses['users']->json(); +$posts = $responses['posts']->json(); +``` + +## Fake HTTP Calls in Tests + +Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. + +Incorrect: +```php +it('syncs user from API', function () { + $service = new UserSyncService; + $service->sync(1); // Hits the real API +}); +``` + +Correct: +```php +it('syncs user from API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + $service = new UserSyncService; + $service->sync(1); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.example.com/users/1'; + }); +}); +``` + +Test failure scenarios too: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/mail.md b/_api_app/.claude/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 000000000..c7f67966e --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,27 @@ +# Mail Best Practices + +## Implement `ShouldQueue` on the Mailable Class + +Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. + +## Use `afterCommit()` on Mailables Inside Transactions + +A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. + +## Use `assertQueued()` Not `assertSent()` for Queued Mailables + +`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence. + +Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. + +Correct: `Mail::assertQueued(OrderShipped::class);` + +## Use Markdown Mailables for Transactional Emails + +Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. + +## Separate Content Tests from Sending Tests + +Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. +Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. +Don't mix them — it conflates concerns and makes tests brittle. \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/migrations.md b/_api_app/.claude/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 000000000..de25aa39c --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,121 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Always use `php artisan make:migration` for consistent naming and timestamps. + +Incorrect (manually created file): +```php +// database/migrations/posts_migration.php ← wrong naming, no timestamp +``` + +Correct (Artisan-generated): +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Use `constrained()` for Foreign Keys + +Automatic naming and referential integrity. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); + +// Non-standard names +$table->foreignId('author_id')->constrained('users'); +``` + +## Never Modify Deployed Migrations + +Once a migration has run in production, treat it as immutable. Create a new migration to change the table. + +Incorrect (editing a deployed migration): +```php +// 2024_01_01_create_posts_table.php — already in production +$table->string('slug')->unique(); // ← added after deployment +``` + +Correct (new migration to alter): +```php +// 2024_03_15_add_slug_to_posts_table.php +Schema::table('posts', function (Blueprint $table) { + $table->string('slug')->unique()->after('title'); +}); +``` + +## Add Indexes in the Migration + +Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->index(); + $table->string('status')->index(); + $table->timestamp('shipped_at')->nullable()->index(); + $table->timestamps(); +}); +``` + +## Mirror Defaults in Model `$attributes` + +When a column has a database default, mirror it in the model so new instances have correct values before saving. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Write Reversible `down()` Methods by Default + +Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. + +```php +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('slug'); + }); +} +``` + +For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. + +## Keep Migrations Focused + +One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). + +Incorrect (partial failure creates unrecoverable state): +```php +public function up(): void +{ + Schema::create('settings', function (Blueprint $table) { ... }); + DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +} +``` + +Correct (separate migrations): +```php +// Migration 1: create_settings_table +Schema::create('settings', function (Blueprint $table) { ... }); + +// Migration 2: seed_default_settings +DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +``` \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/queue-jobs.md b/_api_app/.claude/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 000000000..2f174dfc2 --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,146 @@ +# Queue & Job Best Practices + +## Set `retry_after` Greater Than `timeout` + +If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. + +Incorrect (`retry_after` ≤ `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 90 ← job retried while still running! +``` + +Correct (`retry_after` > `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 180 ← safely longer than any job timeout +``` + +## Use Exponential Backoff + +Use progressively longer delays between retries to avoid hammering failing services. + +Incorrect (fixed retry interval): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + // Default: retries immediately, overwhelming the API +} +``` + +Correct (exponential backoff): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + public $backoff = [1, 5, 10]; +} +``` + +## Implement `ShouldBeUnique` + +Prevent duplicate job processing. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public function uniqueId(): string + { + return $this->order->id; + } + + public $uniqueFor = 3600; +} +``` + +## Always Implement `failed()` + +Handle errors explicitly — don't rely on silent failure. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); +} +``` + +## Rate Limit External API Calls in Jobs + +Use `RateLimited` middleware to throttle jobs calling third-party APIs. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Related Jobs + +Use `Bus::batch()` when jobs should succeed or fail together. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) +->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) +->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed')) +->dispatch(); +``` + +## `retryUntil()` Needs `$tries = 0` + +When using time-based retry limits, set `$tries = 0` to avoid premature failure. + +```php +public $tries = 0; + +public function retryUntil(): \DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use `WithoutOverlapping::untilProcessing()` + +Prevents concurrent execution while allowing new instances to queue. + +```php +public function middleware(): array +{ + return [new WithoutOverlapping($this->product->id)->untilProcessing()]; +} +``` + +Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts. + +## Use Horizon for Complex Queue Scenarios + +Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['high', 'default', 'low'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], + ], +], +``` \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/routing.md b/_api_app/.claude/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 000000000..e288375d7 --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,98 @@ +# Routing & Controllers Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models automatically from route parameters. + +Incorrect: +```php +public function show(int $id) +{ + $post = Post::findOrFail($id); +} +``` + +Correct: +```php +public function show(Post $post) +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Use Scoped Bindings for Nested Resources + +Enforce parent-child relationships automatically. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // $post is automatically scoped to $user +})->scopeBindings(); +``` + +## Use Resource Controllers + +Use `Route::resource()` or `apiResource()` for RESTful endpoints. + +```php +Route::resource('posts', PostController::class); +Route::apiResource('api/posts', Api\PostController::class); +``` + +## Keep Controllers Thin + +Aim for under 10 lines per method. Extract business logic to action or service classes. + +Incorrect: +```php +public function store(Request $request) +{ + $validated = $request->validate([...]); + if ($request->hasFile('image')) { + $request->file('image')->move(public_path('images')); + } + $post = Post::create($validated); + $post->tags()->sync($validated['tags']); + event(new PostCreated($post)); + return redirect()->route('posts.show', $post); +} +``` + +Correct: +```php +public function store(StorePostRequest $request, CreatePostAction $create) +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +## Type-Hint Form Requests + +Type-hinting Form Requests triggers automatic validation and authorization before the method executes. + +Incorrect: +```php +public function store(Request $request): RedirectResponse +{ + $validated = $request->validate([ + 'title' => ['required', 'max:255'], + 'body' => ['required'], + ]); + + Post::create($validated); + + return redirect()->route('posts.index'); +} +``` + +Correct: +```php +public function store(StorePostRequest $request): RedirectResponse +{ + Post::create($request->validated()); + + return redirect()->route('posts.index'); +} +``` \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/scheduling.md b/_api_app/.claude/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 000000000..dfaefa26f --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,39 @@ +# Task Scheduling Best Practices + +## Use `withoutOverlapping()` on Variable-Duration Tasks + +Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. + +## Use `onOneServer()` on Multi-Server Deployments + +Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). + +## Use `runInBackground()` for Concurrent Long Tasks + +By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. + +## Use `environments()` to Restrict Tasks + +Prevent accidental execution of production-only tasks (billing, reporting) on staging. + +```php +Schedule::command('billing:charge')->monthly()->environments(['production']); +``` + +## Use `takeUntilTimeout()` for Time-Bounded Processing + +A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. + +## Use Schedule Groups for Shared Configuration + +Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/security.md b/_api_app/.claude/skills/laravel-best-practices/rules/security.md new file mode 100644 index 000000000..524d47e61 --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,198 @@ +# Security Best Practices + +## Mass Assignment Protection + +Every model must define `$fillable` (whitelist) or `$guarded` (blacklist). + +Incorrect: +```php +class User extends Model +{ + protected $guarded = []; // All fields are mass assignable +} +``` + +Correct: +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Never use `$guarded = []` on models that accept user input. + +## Authorize Every Action + +Use policies or gates in controllers. Never skip authorization. + +Incorrect: +```php +public function update(Request $request, Post $post) +{ + $post->update($request->validated()); +} +``` + +Correct: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); +} +``` + +Or via Form Request: + +```php +public function authorize(): bool +{ + return $this->user()->can('update', $this->route('post')); +} +``` + +## Prevent SQL Injection + +Always use parameter binding. Never interpolate user input into queries. + +Incorrect: +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: +```php +User::where('name', $request->name)->get(); + +// Raw expressions with bindings +User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get(); +``` + +## Escape Output to Prevent XSS + +Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content. + +Incorrect: +```blade +{!! $user->bio !!} +``` + +Correct: +```blade +{{ $user->bio }} +``` + +## CSRF Protection + +Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia. + +Incorrect: +```blade +
+ +
+``` + +Correct: +```blade +
+ @csrf + +
+``` + +## Rate Limit Auth and API Routes + +Apply `throttle` middleware to authentication and API routes. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +## Validate File Uploads + +Validate MIME type, extension, and size. Never trust client-provided filenames. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Store with generated filenames: + +```php +$path = $request->file('avatar')->store('avatars', 'public'); +``` + +## Keep Secrets Out of Code + +Never commit `.env`. Access secrets via `config()` only. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'api_key' => env('API_KEY'), + +// In application code +$key = config('services.api_key'); +``` + +## Audit Dependencies + +Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. + +```bash +composer audit +``` + +## Encrypt Sensitive Database Fields + +Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. + +Incorrect: +```php +class Integration extends Model +{ + protected function casts(): array + { + return [ + 'api_key' => 'string', + ]; + } +} +``` + +Correct: +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/style.md b/_api_app/.claude/skills/laravel-best-practices/rules/style.md new file mode 100644 index 000000000..67af98919 --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,125 @@ +# Conventions & Style + +## Follow Laravel Naming Conventions + +| What | Convention | Good | Bad | +|------|-----------|------|-----| +| Controller | singular | `ArticleController` | `ArticlesController` | +| Model | singular | `User` | `Users` | +| Table | plural, snake_case | `article_comments` | `articleComments` | +| Pivot table | singular alphabetical | `article_user` | `user_article` | +| Column | snake_case, no model name | `meta_title` | `article_meta_title` | +| Foreign key | singular model + `_id` | `article_id` | `articles_id` | +| Route | plural | `articles/1` | `article/1` | +| Route name | snake_case with dots | `users.show_active` | `users.show-active` | +| Method | camelCase | `getAll` | `get_all` | +| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` | +| Collection | descriptive, plural | `$activeUsers` | `$data` | +| Object | descriptive, singular | `$activeUser` | `$users` | +| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` | +| Config | snake_case | `google_calendar.php` | `googleCalendar.php` | +| Enum | singular | `UserType` | `UserTypes` | + +## Prefer Shorter Readable Syntax + +| Verbose | Shorter | +|---------|---------| +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `$request->input('name')` | `$request->name` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `App::make('Class')` | `app('Class')` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()->name` | `->value('name')` | + +## Use Laravel String & Array Helpers + +Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them. + +Strings — use `Str` and fluent `Str::of()` over raw PHP: +```php +// Incorrect +$slug = strtolower(str_replace(' ', '-', $title)); +$short = substr($text, 0, 100) . '...'; +$class = substr(strrchr('App\Models\User', '\'), 1); + +// Correct +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename('App\Models\User'); +``` + +Fluent strings — chain operations for complex transformations: +```php +// Incorrect +$result = strtolower(trim(str_replace('_', '-', $input))); + +// Correct +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`. + +Arrays — use `Arr` over raw PHP: +```php +// Incorrect +$name = isset($array['user']['name']) ? $array['user']['name'] : 'default'; + +// Correct +$name = Arr::get($array, 'user.name', 'default'); +``` + +Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`. + +Numbers — use `Number` for display formatting: +```php +Number::format(1000000); // "1,000,000" +Number::currency(1500, 'USD'); // "$1,500.00" +Number::abbreviate(1000000); // "1M" +Number::fileSize(1024 * 1024); // "1 MB" +Number::percentage(75.5); // "75.5%" +``` + +URIs — use `Uri` for URL manipulation: +```php +$uri = Uri::of('https://example.com/search') + ->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining. + +Use `search-docs` for the full list of available methods — these helpers are extensive. + +## No Inline JS/CSS in Blade + +Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes. + +Incorrect: +```blade +let article = `{{ json_encode($article) }}`; +``` + +Correct: +```blade + +``` + +Pass data to JS via data attributes or use a dedicated PHP-to-JS package. + +## No Unnecessary Comments + +Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected. + +Incorrect: +```php +// Check if there are any joins +if (count((array) $builder->getQuery()->joins) > 0) +``` + +Correct: +```php +if ($this->hasJoins()) +``` \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/testing.md b/_api_app/.claude/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 000000000..d39cc3ed0 --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/testing.md @@ -0,0 +1,43 @@ +# Testing Best Practices + +## Use `LazilyRefreshDatabase` Over `RefreshDatabase` + +`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites. + +## Use Model Assertions Over Raw Database Assertions + +Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` + +Correct: `$this->assertModelExists($user);` + +More expressive, type-safe, and fails with clearer messages. + +## Use Factory States and Sequences + +Named states make tests self-documenting. Sequences eliminate repetitive setup. + +Incorrect: `User::factory()->create(['email_verified_at' => null]);` + +Correct: `User::factory()->unverified()->create();` + +## Use `Exceptions::fake()` to Assert Exception Reporting + +Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. + +## Call `Event::fake()` After Factory Setup + +Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. + +Incorrect: `Event::fake(); $user = User::factory()->create();` + +Correct: `$user = User::factory()->create(); Event::fake();` + +## Use `recycle()` to Share Relationship Instances Across Factories + +Without `recycle()`, nested factories create separate instances of the same conceptual entity. + +```php +Ticket::factory() + ->recycle(Airline::factory()->create()) + ->create(); +``` \ No newline at end of file diff --git a/_api_app/.claude/skills/laravel-best-practices/rules/validation.md b/_api_app/.claude/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 000000000..a20202ff1 --- /dev/null +++ b/_api_app/.claude/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,75 @@ +# Validation & Forms Best Practices + +## Use Form Request Classes + +Extract validation from controllers into dedicated Form Request classes. + +Incorrect: +```php +public function store(Request $request) +{ + $request->validate([ + 'title' => 'required|max:255', + 'body' => 'required', + ]); +} +``` + +Correct: +```php +public function store(StorePostRequest $request) +{ + Post::create($request->validated()); +} +``` + +## Array vs. String Notation for Rules + +Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. + +```php +// Preferred for new code +'email' => ['required', 'email', Rule::unique('users')], + +// Follow existing convention if the project uses string notation +'email' => 'required|email|unique:users', +``` + +## Always Use `validated()` + +Get only validated data. Never use `$request->all()` for mass operations. + +Incorrect: +```php +Post::create($request->all()); +``` + +Correct: +```php +Post::create($request->validated()); +``` + +## Use `Rule::when()` for Conditional Validation + +```php +'company_name' => [ + Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), +], +``` + +## Use the `after()` Method for Custom Validation + +Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($this->quantity > Product::find($this->product_id)?->stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` \ No newline at end of file diff --git a/_api_app/.claude/skills/pest-testing/SKILL.md b/_api_app/.claude/skills/pest-testing/SKILL.md index f6973277f..0761b6e76 100644 --- a/_api_app/.claude/skills/pest-testing/SKILL.md +++ b/_api_app/.claude/skills/pest-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: pest-testing -description: "Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works." +description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit) or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 3 features. Do not use for editing factories, seeders, migrations, controllers, models, or non-test PHP code." license: MIT metadata: author: laravel @@ -8,15 +8,6 @@ metadata: # Pest Testing 3 -## When to Apply - -Activate this skill when: -- Creating new tests (unit or feature) -- Modifying existing tests -- Debugging test failures -- Working with datasets, mocking, or test organization -- Writing architecture tests - ## Documentation Use `search-docs` for detailed Pest 3 patterns and documentation. @@ -35,6 +26,8 @@ All tests must be written using Pest. Use `php artisan make:test --pest {name}`. ### Basic Test Structure +Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`. + ```php it('is true', function () { diff --git a/_api_app/.cursor/skills/laravel-best-practices/SKILL.md b/_api_app/.cursor/skills/laravel-best-practices/SKILL.md new file mode 100644 index 000000000..99018f3ae --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,190 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## Quick Reference + +### 1. Database Performance → `rules/db-performance.md` + +- Eager load with `with()` to prevent N+1 queries +- Enable `Model::preventLazyLoading()` in development +- Select only needed columns, avoid `SELECT *` +- `chunk()` / `chunkById()` for large datasets +- Index columns used in `WHERE`, `ORDER BY`, `JOIN` +- `withCount()` instead of loading relations to count +- `cursor()` for memory-efficient read-only iteration +- Never query in Blade templates + +### 2. Advanced Query Patterns → `rules/advanced-queries.md` + +- `addSelect()` subqueries over eager-loading entire has-many for a single value +- Dynamic relationships via subquery FK + `belongsTo` +- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries +- `setRelation()` to prevent circular N+1 queries +- `whereIn` + `pluck()` over `whereHas` for better index usage +- Two simple queries can beat one complex query +- Compound indexes matching `orderBy` column order +- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) + +### 3. Security → `rules/security.md` + +- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates +- No raw SQL with user input — use Eloquent or query builder +- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes +- Validate MIME type, extension, and size for file uploads +- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields + +### 4. Caching → `rules/caching.md` + +- `Cache::remember()` over manual get/put +- `Cache::flexible()` for stale-while-revalidate on high-traffic data +- `Cache::memo()` to avoid redundant cache hits within a request +- Cache tags to invalidate related groups +- `Cache::add()` for atomic conditional writes +- `once()` to memoize per-request or per-object lifetime +- `Cache::lock()` / `lockForUpdate()` for race conditions +- Failover cache stores in production + +### 5. Eloquent Patterns → `rules/eloquent.md` + +- Correct relationship types with return type hints +- Local scopes for reusable query constraints +- Global scopes sparingly — document their existence +- Attribute casts in the `casts()` method +- Cast date columns, use Carbon instances in templates +- `whereBelongsTo($model)` for cleaner queries +- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries + +### 6. Validation & Forms → `rules/validation.md` + +- Form Request classes, not inline validation +- Array notation `['required', 'email']` for new code; follow existing convention +- `$request->validated()` only — never `$request->all()` +- `Rule::when()` for conditional validation +- `after()` instead of `withValidator()` + +### 7. Configuration → `rules/config.md` + +- `env()` only inside config files +- `App::environment()` or `app()->isProduction()` +- Config, lang files, and constants over hardcoded text + +### 8. Testing Patterns → `rules/testing.md` + +- `LazilyRefreshDatabase` over `RefreshDatabase` for speed +- `assertModelExists()` over raw `assertDatabaseHas()` +- Factory states and sequences over manual overrides +- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before +- `recycle()` to share relationship instances across factories + +### 9. Queue & Job Patterns → `rules/queue-jobs.md` + +- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` +- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency +- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` +- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs +- Horizon for complex multi-queue scenarios + +### 10. Routing & Controllers → `rules/routing.md` + +- Implicit route model binding +- Scoped bindings for nested resources +- `Route::resource()` or `apiResource()` +- Methods under 10 lines — extract to actions/services +- Type-hint Form Requests for auto-validation + +### 11. HTTP Client → `rules/http-client.md` + +- Explicit `timeout` and `connectTimeout` on every request +- `retry()` with exponential backoff for external APIs +- Check response status or use `throw()` +- `Http::pool()` for concurrent independent requests +- `Http::fake()` and `preventStrayRequests()` in tests + +### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` + +- Event discovery over manual registration; `event:cache` in production +- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions +- Queue notifications and mailables with `ShouldQueue` +- On-demand notifications for non-user recipients +- `HasLocalePreference` on notifiable models +- `assertQueued()` not `assertSent()` for queued mailables +- Markdown mailables for transactional emails + +### 13. Error Handling → `rules/error-handling.md` + +- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern +- `ShouldntReport` for exceptions that should never log +- Throttle high-volume exceptions to protect log sinks +- `dontReportDuplicates()` for multi-catch scenarios +- Force JSON rendering for API routes +- Structured context via `context()` on exception classes + +### 14. Task Scheduling → `rules/scheduling.md` + +- `withoutOverlapping()` on variable-duration tasks +- `onOneServer()` on multi-server deployments +- `runInBackground()` for concurrent long tasks +- `environments()` to restrict to appropriate environments +- `takeUntilTimeout()` for time-bounded processing +- Schedule groups for shared configuration + +### 15. Architecture → `rules/architecture.md` + +- Single-purpose Action classes; dependency injection over `app()` helper +- Prefer official Laravel packages and follow conventions, don't override defaults +- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety +- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution + +### 16. Migrations → `rules/migrations.md` + +- Generate migrations with `php artisan make:migration` +- `constrained()` for foreign keys +- Never modify migrations that have run in production +- Add indexes in the migration, not as an afterthought +- Mirror column defaults in model `$attributes` +- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes +- One concern per migration — never mix DDL and DML + +### 17. Collections → `rules/collections.md` + +- Higher-order messages for simple collection operations +- `cursor()` vs. `lazy()` — choose based on relationship needs +- `lazyById()` when updating records while iterating +- `toQuery()` for bulk operations on collections + +### 18. Blade & Views → `rules/blade-views.md` + +- `$attributes->merge()` in component templates +- Blade components over `@include`; `@pushOnce` for per-component scripts +- View Composers for shared view data +- `@aware` for deeply nested component props + +### 19. Conventions & Style → `rules/style.md` + +- Follow Laravel naming conventions for all entities +- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions +- No JS/CSS in Blade, no HTML in PHP classes +- Code should be readable; comments only for config files + +## How to Apply + +Always use a sub-agent to read rule files and explore this skill's content. + +1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) +2. Check sibling files for existing patterns — follow those first per Consistency First +3. Verify API syntax with `search-docs` for the installed Laravel version \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/advanced-queries.md b/_api_app/.cursor/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 000000000..920714a14 --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Patterns + +## Use `addSelect()` Subqueries for Single Values from Has-Many + +Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships via Subquery FK + +Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Use Conditional Aggregates Instead of Multiple Count Queries + +Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Use `setRelation()` to Prevent Circular N+1 + +When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Prefer `whereIn` + Subquery Over `whereHas` + +`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. + +Incorrect (correlated EXISTS re-executes per row): + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Correct (index-friendly subquery, no PHP memory overhead): + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Sometimes Two Simple Queries Beat One Complex Query + +Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. + +## Use Compound Indexes Matching `orderBy` Column Order + +When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query — column order must match the index +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Use Correlated Subqueries for Has-Many Ordering + +When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/architecture.md b/_api_app/.cursor/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 000000000..165056422 --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,202 @@ +# Architecture Best Practices + +## Single-Purpose Action Classes + +Extract discrete business operations into invokable Action classes. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function execute(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Use Dependency Injection + +Always use constructor injection. Avoid `app()` or `resolve()` inside classes. + +Incorrect: +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Correct: +```php +class OrderController extends Controller +{ + public function __construct(private OrderService $service) {} + + public function store(StoreOrderRequest $request) + { + return $this->service->create($request->validated()); + } +} +``` + +## Code to Interfaces + +Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. + +Incorrect (concrete dependency): +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Correct (interface dependency): +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Default Sort by Descending + +When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres. + +Incorrect: +```php +$posts = Post::paginate(); +``` + +Correct: +```php +$posts = Post::latest()->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Prevent race conditions with `Cache::lock()` or `lockForUpdate()`. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level +$product = Product::where('id', $id)->lockForUpdate()->first(); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters. + +Incorrect: +```php +strlen('José'); // 5 (bytes, not characters) +strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte +``` + +Correct: +```php +mb_strlen('José'); // 4 (characters) +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead. + +Incorrect (job overhead for trivial work): +```php +dispatch(new LogPageView($page)); +``` + +Correct (runs after response, same process): +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work. + +## Use `Context` for Request-Scoped Data + +The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Anywhere later — controllers, jobs, log context +$tenantId = Context::get('tenant_id'); +``` + +Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations in parallel using child processes — no async libraries needed. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially. + +## Convention Over Configuration + +Follow Laravel conventions. Don't override defaults unnecessarily. + +Incorrect: +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Correct: +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/blade-views.md b/_api_app/.cursor/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 000000000..c6f8aaf1e --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade & Views Best Practices + +## Use `$attributes->merge()` in Component Templates + +Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. + +## Prefer Blade Components Over `@include` + +`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. + +## Use View Composers for Shared View Data + +If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. + +## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) + +A single view can return either the full page or just a fragment, keeping routing clean. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Use `@aware` for Deeply Nested Component Props + +Avoids re-passing parent props through every level of nested components. \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/caching.md b/_api_app/.cursor/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 000000000..eb3ef3e62 --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,70 @@ +# Caching Best Practices + +## Use `Cache::remember()` Instead of Manual Get/Put + +Atomic pattern prevents race conditions and removes boilerplate. + +Incorrect: +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Use `Cache::flexible()` for Stale-While-Revalidate + +On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background. + +Incorrect: `Cache::remember('users', 300, fn () => User::all());` + +Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function. + +## Use `Cache::memo()` to Avoid Redundant Hits Within a Request + +If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory. + +`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5. + +## Use Cache Tags to Invalidate Related Groups + +Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. + +Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` + +Correct: `Cache::add('lock', true, 10);` + +## Use `once()` for Per-Request Memoization + +`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. + +## Configure Failover Cache Stores in Production + +If Redis goes down, the app falls back to a secondary store automatically. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/collections.md b/_api_app/.cursor/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 000000000..14f683d32 --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,44 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Incorrect: +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Correct: `$users->each->markAsVip();` + +Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. + +## Choose `cursor()` vs. `lazy()` Correctly + +- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). +- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. + +Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. + +Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. + +## Use `toQuery()` for Bulk Operations on Collections + +Avoids manual `whereIn` construction. + +Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` + +Correct: `$users->toQuery()->update([...]);` + +## Use `#[CollectedBy]` for Custom Collection Classes + +More declarative than overriding `newCollection()`. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/config.md b/_api_app/.cursor/skills/laravel-best-practices/rules/config.md new file mode 100644 index 000000000..8fd8f536f --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,73 @@ +# Configuration Best Practices + +## `env()` Only in Config Files + +Direct `env()` calls return `null` when config is cached. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'key' => env('API_KEY'), + +// Application code +$key = config('services.key'); +``` + +## Use Encrypted Env or External Secrets + +Never store production secrets in plain `.env` files in version control. + +Incorrect: +```bash + +# .env committed to repo or shared in Slack + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Correct: +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: +```php +if (env('APP_ENV') === 'production') { +``` + +Correct: +```php +if (app()->isProduction()) { +// or +if (App::environment('production')) { +``` + +## Use Constants and Language Files + +Use class constants instead of hardcoded magic strings for model states, types, and statuses. + +```php +// Incorrect +return $this->type === 'normal'; + +// Correct +return $this->type === self::TYPE_NORMAL; +``` + +If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. + +```php +// Only when lang files already exist in the project +return back()->with('message', __('app.article_added')); +``` \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/db-performance.md b/_api_app/.cursor/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 000000000..8fb719377 --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,192 @@ +# Database Performance Best Practices + +## Always Eager Load Relationships + +Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. + +Incorrect (N+1 — executes 1 + N queries): +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Correct (2 queries total): +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads to select only needed columns (always include the foreign key): + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. + +## Select Only Needed Columns + +Avoid `SELECT *` — especially when tables have large text or JSON columns. + +Incorrect: +```php +$posts = Post::with('author')->get(); +``` + +Correct: +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. + +## Chunk Large Datasets + +Never load thousands of records at once. Use chunking for batch processing. + +Incorrect: +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Correct: +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +## Add Database Indexes + +Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index()->constrained(); + $table->string('status')->index(); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). + +## Use `withCount()` for Counting Relations + +Never load entire collections just to count them. + +Incorrect: +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Correct: +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Use `cursor()` for Memory-Efficient Iteration + +For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. + +Incorrect: +```php +$users = User::where('active', true)->get(); +``` + +Correct: +```php +foreach (User::where('active', true)->cursor() as $user) { + ProcessUser::dispatch($user->id); +} +``` + +Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. + +## No Queries in Blade Templates + +Never execute queries in Blade templates. Pass data from controllers. + +Incorrect: +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Correct: +```php +// Controller +$users = User::with('profile')->get(); +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/eloquent.md b/_api_app/.cursor/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 000000000..09cd66a05 --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,148 @@ +# Eloquent Best Practices + +## Use Correct Relationship Types + +Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Incorrect: +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Correct: +```php +public function scopeActive(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Incorrect (global scope for a conditional filter): +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} +// Now admin panels, reports, and background jobs all silently skip drafts +``` + +Correct (local scope you opt into): +```php +public function scopePublished(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date Columns Properly + +Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. + +Incorrect: +```blade +{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} +``` + +Correct: +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +Cleaner than manually specifying foreign keys. + +Incorrect: +```php +Post::where('user_id', $user->id)->get(); +``` + +Correct: +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Avoid Hardcoded Table Names in Queries + +Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). + +Incorrect: +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Correct — reference the model's table: +```php +DB::table((new User)->getTable())->where('active', true)->get(); + +// Even better — use Eloquent or the query builder instead of raw SQL +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. + +**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/error-handling.md b/_api_app/.cursor/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 000000000..bb8e7a387 --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,72 @@ +# Error Handling Best Practices + +## Exception Reporting and Rendering + +There are two valid approaches — choose one and apply it consistently across the project. + +**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void { /* custom reporting */ } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +Check the existing codebase and follow whichever pattern is already established. + +## Use `ShouldntReport` for Exceptions That Should Never Log + +More discoverable than listing classes in `dontReport()`. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exceptions + +A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. + +## Enable `dontReportDuplicates()` + +Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. + +## Force JSON Error Rendering for API Routes + +Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/events-notifications.md b/_api_app/.cursor/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 000000000..bc43f1997 --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,48 @@ +# Events & Notifications Best Practices + +## Rely on Event Discovery + +Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. + +## Run `event:cache` in Production Deploy + +Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Always Queue Notifications + +Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Use `afterCommit()` on Notifications in Transactions + +Same race condition as events — the queued notification job may run before the transaction commits. + +## Route Notification Channels to Dedicated Queues + +Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/http-client.md b/_api_app/.cursor/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 000000000..0a7876ed3 --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,160 @@ +# HTTP Client Best Practices + +## Always Set Explicit Timeouts + +The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users'); +``` + +Correct: +```php +$response = Http::timeout(5) + ->connectTimeout(3) + ->get('https://api.example.com/users'); +``` + +For service-specific clients, define timeouts in a macro: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->timeout(10) + ->connectTimeout(3) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Use Retry with Backoff for External APIs + +External APIs have transient failures. Use `retry()` with increasing delays. + +Incorrect: +```php +$response = Http::post('https://api.stripe.com/v1/charges', $data); + +if ($response->failed()) { + throw new PaymentFailedException('Charge failed'); +} +``` + +Correct: +```php +$response = Http::retry([100, 500, 1000]) + ->timeout(10) + ->post('https://api.stripe.com/v1/charges', $data); +``` + +Only retry on specific errors: + +```php +$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()); +})->post('https://api.example.com/data'); +``` + +## Handle Errors Explicitly + +The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users/1'); +$user = $response->json(); // Could be an error body +``` + +Correct: +```php +$response = Http::timeout(5) + ->get('https://api.example.com/users/1') + ->throw(); + +$user = $response->json(); +``` + +For graceful degradation: + +```php +$response = Http::get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Use Request Pooling for Concurrent Requests + +When making multiple independent API calls, use `Http::pool()` instead of sequential calls. + +Incorrect: +```php +$users = Http::get('https://api.example.com/users')->json(); +$posts = Http::get('https://api.example.com/posts')->json(); +$comments = Http::get('https://api.example.com/comments')->json(); +``` + +Correct: +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->get('https://api.example.com/users'), + $pool->as('posts')->get('https://api.example.com/posts'), + $pool->as('comments')->get('https://api.example.com/comments'), +]); + +$users = $responses['users']->json(); +$posts = $responses['posts']->json(); +``` + +## Fake HTTP Calls in Tests + +Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. + +Incorrect: +```php +it('syncs user from API', function () { + $service = new UserSyncService; + $service->sync(1); // Hits the real API +}); +``` + +Correct: +```php +it('syncs user from API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + $service = new UserSyncService; + $service->sync(1); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.example.com/users/1'; + }); +}); +``` + +Test failure scenarios too: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/mail.md b/_api_app/.cursor/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 000000000..c7f67966e --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,27 @@ +# Mail Best Practices + +## Implement `ShouldQueue` on the Mailable Class + +Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. + +## Use `afterCommit()` on Mailables Inside Transactions + +A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. + +## Use `assertQueued()` Not `assertSent()` for Queued Mailables + +`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence. + +Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. + +Correct: `Mail::assertQueued(OrderShipped::class);` + +## Use Markdown Mailables for Transactional Emails + +Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. + +## Separate Content Tests from Sending Tests + +Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. +Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. +Don't mix them — it conflates concerns and makes tests brittle. \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/migrations.md b/_api_app/.cursor/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 000000000..de25aa39c --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,121 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Always use `php artisan make:migration` for consistent naming and timestamps. + +Incorrect (manually created file): +```php +// database/migrations/posts_migration.php ← wrong naming, no timestamp +``` + +Correct (Artisan-generated): +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Use `constrained()` for Foreign Keys + +Automatic naming and referential integrity. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); + +// Non-standard names +$table->foreignId('author_id')->constrained('users'); +``` + +## Never Modify Deployed Migrations + +Once a migration has run in production, treat it as immutable. Create a new migration to change the table. + +Incorrect (editing a deployed migration): +```php +// 2024_01_01_create_posts_table.php — already in production +$table->string('slug')->unique(); // ← added after deployment +``` + +Correct (new migration to alter): +```php +// 2024_03_15_add_slug_to_posts_table.php +Schema::table('posts', function (Blueprint $table) { + $table->string('slug')->unique()->after('title'); +}); +``` + +## Add Indexes in the Migration + +Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->index(); + $table->string('status')->index(); + $table->timestamp('shipped_at')->nullable()->index(); + $table->timestamps(); +}); +``` + +## Mirror Defaults in Model `$attributes` + +When a column has a database default, mirror it in the model so new instances have correct values before saving. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Write Reversible `down()` Methods by Default + +Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. + +```php +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('slug'); + }); +} +``` + +For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. + +## Keep Migrations Focused + +One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). + +Incorrect (partial failure creates unrecoverable state): +```php +public function up(): void +{ + Schema::create('settings', function (Blueprint $table) { ... }); + DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +} +``` + +Correct (separate migrations): +```php +// Migration 1: create_settings_table +Schema::create('settings', function (Blueprint $table) { ... }); + +// Migration 2: seed_default_settings +DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +``` \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/queue-jobs.md b/_api_app/.cursor/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 000000000..2f174dfc2 --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,146 @@ +# Queue & Job Best Practices + +## Set `retry_after` Greater Than `timeout` + +If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. + +Incorrect (`retry_after` ≤ `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 90 ← job retried while still running! +``` + +Correct (`retry_after` > `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 180 ← safely longer than any job timeout +``` + +## Use Exponential Backoff + +Use progressively longer delays between retries to avoid hammering failing services. + +Incorrect (fixed retry interval): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + // Default: retries immediately, overwhelming the API +} +``` + +Correct (exponential backoff): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + public $backoff = [1, 5, 10]; +} +``` + +## Implement `ShouldBeUnique` + +Prevent duplicate job processing. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public function uniqueId(): string + { + return $this->order->id; + } + + public $uniqueFor = 3600; +} +``` + +## Always Implement `failed()` + +Handle errors explicitly — don't rely on silent failure. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); +} +``` + +## Rate Limit External API Calls in Jobs + +Use `RateLimited` middleware to throttle jobs calling third-party APIs. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Related Jobs + +Use `Bus::batch()` when jobs should succeed or fail together. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) +->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) +->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed')) +->dispatch(); +``` + +## `retryUntil()` Needs `$tries = 0` + +When using time-based retry limits, set `$tries = 0` to avoid premature failure. + +```php +public $tries = 0; + +public function retryUntil(): \DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use `WithoutOverlapping::untilProcessing()` + +Prevents concurrent execution while allowing new instances to queue. + +```php +public function middleware(): array +{ + return [new WithoutOverlapping($this->product->id)->untilProcessing()]; +} +``` + +Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts. + +## Use Horizon for Complex Queue Scenarios + +Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['high', 'default', 'low'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], + ], +], +``` \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/routing.md b/_api_app/.cursor/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 000000000..e288375d7 --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,98 @@ +# Routing & Controllers Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models automatically from route parameters. + +Incorrect: +```php +public function show(int $id) +{ + $post = Post::findOrFail($id); +} +``` + +Correct: +```php +public function show(Post $post) +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Use Scoped Bindings for Nested Resources + +Enforce parent-child relationships automatically. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // $post is automatically scoped to $user +})->scopeBindings(); +``` + +## Use Resource Controllers + +Use `Route::resource()` or `apiResource()` for RESTful endpoints. + +```php +Route::resource('posts', PostController::class); +Route::apiResource('api/posts', Api\PostController::class); +``` + +## Keep Controllers Thin + +Aim for under 10 lines per method. Extract business logic to action or service classes. + +Incorrect: +```php +public function store(Request $request) +{ + $validated = $request->validate([...]); + if ($request->hasFile('image')) { + $request->file('image')->move(public_path('images')); + } + $post = Post::create($validated); + $post->tags()->sync($validated['tags']); + event(new PostCreated($post)); + return redirect()->route('posts.show', $post); +} +``` + +Correct: +```php +public function store(StorePostRequest $request, CreatePostAction $create) +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +## Type-Hint Form Requests + +Type-hinting Form Requests triggers automatic validation and authorization before the method executes. + +Incorrect: +```php +public function store(Request $request): RedirectResponse +{ + $validated = $request->validate([ + 'title' => ['required', 'max:255'], + 'body' => ['required'], + ]); + + Post::create($validated); + + return redirect()->route('posts.index'); +} +``` + +Correct: +```php +public function store(StorePostRequest $request): RedirectResponse +{ + Post::create($request->validated()); + + return redirect()->route('posts.index'); +} +``` \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/scheduling.md b/_api_app/.cursor/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 000000000..dfaefa26f --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,39 @@ +# Task Scheduling Best Practices + +## Use `withoutOverlapping()` on Variable-Duration Tasks + +Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. + +## Use `onOneServer()` on Multi-Server Deployments + +Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). + +## Use `runInBackground()` for Concurrent Long Tasks + +By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. + +## Use `environments()` to Restrict Tasks + +Prevent accidental execution of production-only tasks (billing, reporting) on staging. + +```php +Schedule::command('billing:charge')->monthly()->environments(['production']); +``` + +## Use `takeUntilTimeout()` for Time-Bounded Processing + +A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. + +## Use Schedule Groups for Shared Configuration + +Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/security.md b/_api_app/.cursor/skills/laravel-best-practices/rules/security.md new file mode 100644 index 000000000..524d47e61 --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,198 @@ +# Security Best Practices + +## Mass Assignment Protection + +Every model must define `$fillable` (whitelist) or `$guarded` (blacklist). + +Incorrect: +```php +class User extends Model +{ + protected $guarded = []; // All fields are mass assignable +} +``` + +Correct: +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Never use `$guarded = []` on models that accept user input. + +## Authorize Every Action + +Use policies or gates in controllers. Never skip authorization. + +Incorrect: +```php +public function update(Request $request, Post $post) +{ + $post->update($request->validated()); +} +``` + +Correct: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); +} +``` + +Or via Form Request: + +```php +public function authorize(): bool +{ + return $this->user()->can('update', $this->route('post')); +} +``` + +## Prevent SQL Injection + +Always use parameter binding. Never interpolate user input into queries. + +Incorrect: +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: +```php +User::where('name', $request->name)->get(); + +// Raw expressions with bindings +User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get(); +``` + +## Escape Output to Prevent XSS + +Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content. + +Incorrect: +```blade +{!! $user->bio !!} +``` + +Correct: +```blade +{{ $user->bio }} +``` + +## CSRF Protection + +Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia. + +Incorrect: +```blade +
+ +
+``` + +Correct: +```blade +
+ @csrf + +
+``` + +## Rate Limit Auth and API Routes + +Apply `throttle` middleware to authentication and API routes. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +## Validate File Uploads + +Validate MIME type, extension, and size. Never trust client-provided filenames. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Store with generated filenames: + +```php +$path = $request->file('avatar')->store('avatars', 'public'); +``` + +## Keep Secrets Out of Code + +Never commit `.env`. Access secrets via `config()` only. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'api_key' => env('API_KEY'), + +// In application code +$key = config('services.api_key'); +``` + +## Audit Dependencies + +Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. + +```bash +composer audit +``` + +## Encrypt Sensitive Database Fields + +Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. + +Incorrect: +```php +class Integration extends Model +{ + protected function casts(): array + { + return [ + 'api_key' => 'string', + ]; + } +} +``` + +Correct: +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/style.md b/_api_app/.cursor/skills/laravel-best-practices/rules/style.md new file mode 100644 index 000000000..67af98919 --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,125 @@ +# Conventions & Style + +## Follow Laravel Naming Conventions + +| What | Convention | Good | Bad | +|------|-----------|------|-----| +| Controller | singular | `ArticleController` | `ArticlesController` | +| Model | singular | `User` | `Users` | +| Table | plural, snake_case | `article_comments` | `articleComments` | +| Pivot table | singular alphabetical | `article_user` | `user_article` | +| Column | snake_case, no model name | `meta_title` | `article_meta_title` | +| Foreign key | singular model + `_id` | `article_id` | `articles_id` | +| Route | plural | `articles/1` | `article/1` | +| Route name | snake_case with dots | `users.show_active` | `users.show-active` | +| Method | camelCase | `getAll` | `get_all` | +| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` | +| Collection | descriptive, plural | `$activeUsers` | `$data` | +| Object | descriptive, singular | `$activeUser` | `$users` | +| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` | +| Config | snake_case | `google_calendar.php` | `googleCalendar.php` | +| Enum | singular | `UserType` | `UserTypes` | + +## Prefer Shorter Readable Syntax + +| Verbose | Shorter | +|---------|---------| +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `$request->input('name')` | `$request->name` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `App::make('Class')` | `app('Class')` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()->name` | `->value('name')` | + +## Use Laravel String & Array Helpers + +Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them. + +Strings — use `Str` and fluent `Str::of()` over raw PHP: +```php +// Incorrect +$slug = strtolower(str_replace(' ', '-', $title)); +$short = substr($text, 0, 100) . '...'; +$class = substr(strrchr('App\Models\User', '\'), 1); + +// Correct +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename('App\Models\User'); +``` + +Fluent strings — chain operations for complex transformations: +```php +// Incorrect +$result = strtolower(trim(str_replace('_', '-', $input))); + +// Correct +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`. + +Arrays — use `Arr` over raw PHP: +```php +// Incorrect +$name = isset($array['user']['name']) ? $array['user']['name'] : 'default'; + +// Correct +$name = Arr::get($array, 'user.name', 'default'); +``` + +Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`. + +Numbers — use `Number` for display formatting: +```php +Number::format(1000000); // "1,000,000" +Number::currency(1500, 'USD'); // "$1,500.00" +Number::abbreviate(1000000); // "1M" +Number::fileSize(1024 * 1024); // "1 MB" +Number::percentage(75.5); // "75.5%" +``` + +URIs — use `Uri` for URL manipulation: +```php +$uri = Uri::of('https://example.com/search') + ->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining. + +Use `search-docs` for the full list of available methods — these helpers are extensive. + +## No Inline JS/CSS in Blade + +Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes. + +Incorrect: +```blade +let article = `{{ json_encode($article) }}`; +``` + +Correct: +```blade + +``` + +Pass data to JS via data attributes or use a dedicated PHP-to-JS package. + +## No Unnecessary Comments + +Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected. + +Incorrect: +```php +// Check if there are any joins +if (count((array) $builder->getQuery()->joins) > 0) +``` + +Correct: +```php +if ($this->hasJoins()) +``` \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/testing.md b/_api_app/.cursor/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 000000000..d39cc3ed0 --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/testing.md @@ -0,0 +1,43 @@ +# Testing Best Practices + +## Use `LazilyRefreshDatabase` Over `RefreshDatabase` + +`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites. + +## Use Model Assertions Over Raw Database Assertions + +Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` + +Correct: `$this->assertModelExists($user);` + +More expressive, type-safe, and fails with clearer messages. + +## Use Factory States and Sequences + +Named states make tests self-documenting. Sequences eliminate repetitive setup. + +Incorrect: `User::factory()->create(['email_verified_at' => null]);` + +Correct: `User::factory()->unverified()->create();` + +## Use `Exceptions::fake()` to Assert Exception Reporting + +Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. + +## Call `Event::fake()` After Factory Setup + +Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. + +Incorrect: `Event::fake(); $user = User::factory()->create();` + +Correct: `$user = User::factory()->create(); Event::fake();` + +## Use `recycle()` to Share Relationship Instances Across Factories + +Without `recycle()`, nested factories create separate instances of the same conceptual entity. + +```php +Ticket::factory() + ->recycle(Airline::factory()->create()) + ->create(); +``` \ No newline at end of file diff --git a/_api_app/.cursor/skills/laravel-best-practices/rules/validation.md b/_api_app/.cursor/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 000000000..a20202ff1 --- /dev/null +++ b/_api_app/.cursor/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,75 @@ +# Validation & Forms Best Practices + +## Use Form Request Classes + +Extract validation from controllers into dedicated Form Request classes. + +Incorrect: +```php +public function store(Request $request) +{ + $request->validate([ + 'title' => 'required|max:255', + 'body' => 'required', + ]); +} +``` + +Correct: +```php +public function store(StorePostRequest $request) +{ + Post::create($request->validated()); +} +``` + +## Array vs. String Notation for Rules + +Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. + +```php +// Preferred for new code +'email' => ['required', 'email', Rule::unique('users')], + +// Follow existing convention if the project uses string notation +'email' => 'required|email|unique:users', +``` + +## Always Use `validated()` + +Get only validated data. Never use `$request->all()` for mass operations. + +Incorrect: +```php +Post::create($request->all()); +``` + +Correct: +```php +Post::create($request->validated()); +``` + +## Use `Rule::when()` for Conditional Validation + +```php +'company_name' => [ + Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), +], +``` + +## Use the `after()` Method for Custom Validation + +Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($this->quantity > Product::find($this->product_id)?->stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` \ No newline at end of file diff --git a/_api_app/.cursor/skills/pest-testing/SKILL.md b/_api_app/.cursor/skills/pest-testing/SKILL.md index f6973277f..0761b6e76 100644 --- a/_api_app/.cursor/skills/pest-testing/SKILL.md +++ b/_api_app/.cursor/skills/pest-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: pest-testing -description: "Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works." +description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit) or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 3 features. Do not use for editing factories, seeders, migrations, controllers, models, or non-test PHP code." license: MIT metadata: author: laravel @@ -8,15 +8,6 @@ metadata: # Pest Testing 3 -## When to Apply - -Activate this skill when: -- Creating new tests (unit or feature) -- Modifying existing tests -- Debugging test failures -- Working with datasets, mocking, or test organization -- Writing architecture tests - ## Documentation Use `search-docs` for detailed Pest 3 patterns and documentation. @@ -35,6 +26,8 @@ All tests must be written using Pest. Use `php artisan make:test --pest {name}`. ### Basic Test Structure +Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`. + ```php it('is true', function () { diff --git a/_api_app/.github/skills/laravel-best-practices/SKILL.md b/_api_app/.github/skills/laravel-best-practices/SKILL.md new file mode 100644 index 000000000..99018f3ae --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,190 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## Quick Reference + +### 1. Database Performance → `rules/db-performance.md` + +- Eager load with `with()` to prevent N+1 queries +- Enable `Model::preventLazyLoading()` in development +- Select only needed columns, avoid `SELECT *` +- `chunk()` / `chunkById()` for large datasets +- Index columns used in `WHERE`, `ORDER BY`, `JOIN` +- `withCount()` instead of loading relations to count +- `cursor()` for memory-efficient read-only iteration +- Never query in Blade templates + +### 2. Advanced Query Patterns → `rules/advanced-queries.md` + +- `addSelect()` subqueries over eager-loading entire has-many for a single value +- Dynamic relationships via subquery FK + `belongsTo` +- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries +- `setRelation()` to prevent circular N+1 queries +- `whereIn` + `pluck()` over `whereHas` for better index usage +- Two simple queries can beat one complex query +- Compound indexes matching `orderBy` column order +- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) + +### 3. Security → `rules/security.md` + +- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates +- No raw SQL with user input — use Eloquent or query builder +- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes +- Validate MIME type, extension, and size for file uploads +- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields + +### 4. Caching → `rules/caching.md` + +- `Cache::remember()` over manual get/put +- `Cache::flexible()` for stale-while-revalidate on high-traffic data +- `Cache::memo()` to avoid redundant cache hits within a request +- Cache tags to invalidate related groups +- `Cache::add()` for atomic conditional writes +- `once()` to memoize per-request or per-object lifetime +- `Cache::lock()` / `lockForUpdate()` for race conditions +- Failover cache stores in production + +### 5. Eloquent Patterns → `rules/eloquent.md` + +- Correct relationship types with return type hints +- Local scopes for reusable query constraints +- Global scopes sparingly — document their existence +- Attribute casts in the `casts()` method +- Cast date columns, use Carbon instances in templates +- `whereBelongsTo($model)` for cleaner queries +- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries + +### 6. Validation & Forms → `rules/validation.md` + +- Form Request classes, not inline validation +- Array notation `['required', 'email']` for new code; follow existing convention +- `$request->validated()` only — never `$request->all()` +- `Rule::when()` for conditional validation +- `after()` instead of `withValidator()` + +### 7. Configuration → `rules/config.md` + +- `env()` only inside config files +- `App::environment()` or `app()->isProduction()` +- Config, lang files, and constants over hardcoded text + +### 8. Testing Patterns → `rules/testing.md` + +- `LazilyRefreshDatabase` over `RefreshDatabase` for speed +- `assertModelExists()` over raw `assertDatabaseHas()` +- Factory states and sequences over manual overrides +- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before +- `recycle()` to share relationship instances across factories + +### 9. Queue & Job Patterns → `rules/queue-jobs.md` + +- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` +- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency +- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` +- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs +- Horizon for complex multi-queue scenarios + +### 10. Routing & Controllers → `rules/routing.md` + +- Implicit route model binding +- Scoped bindings for nested resources +- `Route::resource()` or `apiResource()` +- Methods under 10 lines — extract to actions/services +- Type-hint Form Requests for auto-validation + +### 11. HTTP Client → `rules/http-client.md` + +- Explicit `timeout` and `connectTimeout` on every request +- `retry()` with exponential backoff for external APIs +- Check response status or use `throw()` +- `Http::pool()` for concurrent independent requests +- `Http::fake()` and `preventStrayRequests()` in tests + +### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` + +- Event discovery over manual registration; `event:cache` in production +- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions +- Queue notifications and mailables with `ShouldQueue` +- On-demand notifications for non-user recipients +- `HasLocalePreference` on notifiable models +- `assertQueued()` not `assertSent()` for queued mailables +- Markdown mailables for transactional emails + +### 13. Error Handling → `rules/error-handling.md` + +- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern +- `ShouldntReport` for exceptions that should never log +- Throttle high-volume exceptions to protect log sinks +- `dontReportDuplicates()` for multi-catch scenarios +- Force JSON rendering for API routes +- Structured context via `context()` on exception classes + +### 14. Task Scheduling → `rules/scheduling.md` + +- `withoutOverlapping()` on variable-duration tasks +- `onOneServer()` on multi-server deployments +- `runInBackground()` for concurrent long tasks +- `environments()` to restrict to appropriate environments +- `takeUntilTimeout()` for time-bounded processing +- Schedule groups for shared configuration + +### 15. Architecture → `rules/architecture.md` + +- Single-purpose Action classes; dependency injection over `app()` helper +- Prefer official Laravel packages and follow conventions, don't override defaults +- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety +- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution + +### 16. Migrations → `rules/migrations.md` + +- Generate migrations with `php artisan make:migration` +- `constrained()` for foreign keys +- Never modify migrations that have run in production +- Add indexes in the migration, not as an afterthought +- Mirror column defaults in model `$attributes` +- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes +- One concern per migration — never mix DDL and DML + +### 17. Collections → `rules/collections.md` + +- Higher-order messages for simple collection operations +- `cursor()` vs. `lazy()` — choose based on relationship needs +- `lazyById()` when updating records while iterating +- `toQuery()` for bulk operations on collections + +### 18. Blade & Views → `rules/blade-views.md` + +- `$attributes->merge()` in component templates +- Blade components over `@include`; `@pushOnce` for per-component scripts +- View Composers for shared view data +- `@aware` for deeply nested component props + +### 19. Conventions & Style → `rules/style.md` + +- Follow Laravel naming conventions for all entities +- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions +- No JS/CSS in Blade, no HTML in PHP classes +- Code should be readable; comments only for config files + +## How to Apply + +Always use a sub-agent to read rule files and explore this skill's content. + +1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) +2. Check sibling files for existing patterns — follow those first per Consistency First +3. Verify API syntax with `search-docs` for the installed Laravel version \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/advanced-queries.md b/_api_app/.github/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 000000000..920714a14 --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Patterns + +## Use `addSelect()` Subqueries for Single Values from Has-Many + +Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships via Subquery FK + +Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Use Conditional Aggregates Instead of Multiple Count Queries + +Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Use `setRelation()` to Prevent Circular N+1 + +When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Prefer `whereIn` + Subquery Over `whereHas` + +`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory. + +Incorrect (correlated EXISTS re-executes per row): + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Correct (index-friendly subquery, no PHP memory overhead): + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Sometimes Two Simple Queries Beat One Complex Query + +Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index. + +## Use Compound Indexes Matching `orderBy` Column Order + +When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query — column order must match the index +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Use Correlated Subqueries for Has-Many Ordering + +When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/architecture.md b/_api_app/.github/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 000000000..165056422 --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,202 @@ +# Architecture Best Practices + +## Single-Purpose Action Classes + +Extract discrete business operations into invokable Action classes. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function execute(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Use Dependency Injection + +Always use constructor injection. Avoid `app()` or `resolve()` inside classes. + +Incorrect: +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Correct: +```php +class OrderController extends Controller +{ + public function __construct(private OrderService $service) {} + + public function store(StoreOrderRequest $request) + { + return $this->service->create($request->validated()); + } +} +``` + +## Code to Interfaces + +Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. + +Incorrect (concrete dependency): +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Correct (interface dependency): +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Default Sort by Descending + +When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres. + +Incorrect: +```php +$posts = Post::paginate(); +``` + +Correct: +```php +$posts = Post::latest()->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Prevent race conditions with `Cache::lock()` or `lockForUpdate()`. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level +$product = Product::where('id', $id)->lockForUpdate()->first(); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters. + +Incorrect: +```php +strlen('José'); // 5 (bytes, not characters) +strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte +``` + +Correct: +```php +mb_strlen('José'); // 4 (characters) +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead. + +Incorrect (job overhead for trivial work): +```php +dispatch(new LogPageView($page)); +``` + +Correct (runs after response, same process): +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work. + +## Use `Context` for Request-Scoped Data + +The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Anywhere later — controllers, jobs, log context +$tenantId = Context::get('tenant_id'); +``` + +Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations in parallel using child processes — no async libraries needed. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially. + +## Convention Over Configuration + +Follow Laravel conventions. Don't override defaults unnecessarily. + +Incorrect: +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Correct: +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/blade-views.md b/_api_app/.github/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 000000000..c6f8aaf1e --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade & Views Best Practices + +## Use `$attributes->merge()` in Component Templates + +Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once. + +## Prefer Blade Components Over `@include` + +`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots. + +## Use View Composers for Shared View Data + +If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it. + +## Use Blade Fragments for Partial Re-Renders (htmx/Turbo) + +A single view can return either the full page or just a fragment, keeping routing clean. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Use `@aware` for Deeply Nested Component Props + +Avoids re-passing parent props through every level of nested components. \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/caching.md b/_api_app/.github/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 000000000..eb3ef3e62 --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,70 @@ +# Caching Best Practices + +## Use `Cache::remember()` Instead of Manual Get/Put + +Atomic pattern prevents race conditions and removes boilerplate. + +Incorrect: +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Use `Cache::flexible()` for Stale-While-Revalidate + +On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background. + +Incorrect: `Cache::remember('users', 300, fn () => User::all());` + +Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function. + +## Use `Cache::memo()` to Avoid Redundant Hits Within a Request + +If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory. + +`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5. + +## Use Cache Tags to Invalidate Related Groups + +Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` only writes if the key does not exist — atomic, no race condition between checking and writing. + +Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }` + +Correct: `Cache::add('lock', true, 10);` + +## Use `once()` for Per-Request Memoization + +`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching. + +## Configure Failover Cache Stores in Production + +If Redis goes down, the app falls back to a secondary store automatically. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/collections.md b/_api_app/.github/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 000000000..14f683d32 --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,44 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Incorrect: +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Correct: `$users->each->markAsVip();` + +Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc. + +## Choose `cursor()` vs. `lazy()` Correctly + +- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk). +- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading. + +Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored. + +Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work. + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation. + +## Use `toQuery()` for Bulk Operations on Collections + +Avoids manual `whereIn` construction. + +Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);` + +Correct: `$users->toQuery()->update([...]);` + +## Use `#[CollectedBy]` for Custom Collection Classes + +More declarative than overriding `newCollection()`. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/config.md b/_api_app/.github/skills/laravel-best-practices/rules/config.md new file mode 100644 index 000000000..8fd8f536f --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,73 @@ +# Configuration Best Practices + +## `env()` Only in Config Files + +Direct `env()` calls return `null` when config is cached. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'key' => env('API_KEY'), + +// Application code +$key = config('services.key'); +``` + +## Use Encrypted Env or External Secrets + +Never store production secrets in plain `.env` files in version control. + +Incorrect: +```bash + +# .env committed to repo or shared in Slack + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Correct: +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: +```php +if (env('APP_ENV') === 'production') { +``` + +Correct: +```php +if (app()->isProduction()) { +// or +if (App::environment('production')) { +``` + +## Use Constants and Language Files + +Use class constants instead of hardcoded magic strings for model states, types, and statuses. + +```php +// Incorrect +return $this->type === 'normal'; + +// Correct +return $this->type === self::TYPE_NORMAL; +``` + +If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there. + +```php +// Only when lang files already exist in the project +return back()->with('message', __('app.article_added')); +``` \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/db-performance.md b/_api_app/.github/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 000000000..8fb719377 --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,192 @@ +# Database Performance Best Practices + +## Always Eager Load Relationships + +Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront. + +Incorrect (N+1 — executes 1 + N queries): +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Correct (2 queries total): +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads to select only needed columns (always include the foreign key): + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded. + +## Select Only Needed Columns + +Avoid `SELECT *` — especially when tables have large text or JSON columns. + +Incorrect: +```php +$posts = Post::with('author')->get(); +``` + +Correct: +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match. + +## Chunk Large Datasets + +Never load thousands of records at once. Use chunking for batch processing. + +Incorrect: +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Correct: +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +## Add Database Indexes + +Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index()->constrained(); + $table->string('status')->index(); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`). + +## Use `withCount()` for Counting Relations + +Never load entire collections just to count them. + +Incorrect: +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Correct: +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Use `cursor()` for Memory-Efficient Iteration + +For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator. + +Incorrect: +```php +$users = User::where('active', true)->get(); +``` + +Correct: +```php +foreach (User::where('active', true)->cursor() as $user) { + ProcessUser::dispatch($user->id); +} +``` + +Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records. + +## No Queries in Blade Templates + +Never execute queries in Blade templates. Pass data from controllers. + +Incorrect: +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Correct: +```php +// Controller +$users = User::with('profile')->get(); +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/eloquent.md b/_api_app/.github/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 000000000..09cd66a05 --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,148 @@ +# Eloquent Best Practices + +## Use Correct Relationship Types + +Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Incorrect: +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Correct: +```php +public function scopeActive(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Incorrect (global scope for a conditional filter): +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} +// Now admin panels, reports, and background jobs all silently skip drafts +``` + +Correct (local scope you opt into): +```php +public function scopePublished(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date Columns Properly + +Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. + +Incorrect: +```blade +{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} +``` + +Correct: +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +Cleaner than manually specifying foreign keys. + +Incorrect: +```php +Post::where('user_id', $user->id)->get(); +``` + +Correct: +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Avoid Hardcoded Table Names in Queries + +Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). + +Incorrect: +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Correct — reference the model's table: +```php +DB::table((new User)->getTable())->where('active', true)->get(); + +// Even better — use Eloquent or the query builder instead of raw SQL +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. + +**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/error-handling.md b/_api_app/.github/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 000000000..bb8e7a387 --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,72 @@ +# Error Handling Best Practices + +## Exception Reporting and Rendering + +There are two valid approaches — choose one and apply it consistently across the project. + +**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void { /* custom reporting */ } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { /* ... */ }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +Check the existing codebase and follow whichever pattern is already established. + +## Use `ShouldntReport` for Exceptions That Should Never Log + +More discoverable than listing classes in `dontReport()`. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exceptions + +A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type. + +## Enable `dontReportDuplicates()` + +Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks. + +## Force JSON Error Rendering for API Routes + +Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/events-notifications.md b/_api_app/.github/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 000000000..bc43f1997 --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,48 @@ +# Events & Notifications Best Practices + +## Rely on Event Discovery + +Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`. + +## Run `event:cache` in Production Deploy + +Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Always Queue Notifications + +Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Use `afterCommit()` on Notifications in Transactions + +Same race condition as events — the queued notification job may run before the transaction commits. + +## Route Notification Channels to Dedicated Queues + +Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed. \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/http-client.md b/_api_app/.github/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 000000000..0a7876ed3 --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,160 @@ +# HTTP Client Best Practices + +## Always Set Explicit Timeouts + +The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users'); +``` + +Correct: +```php +$response = Http::timeout(5) + ->connectTimeout(3) + ->get('https://api.example.com/users'); +``` + +For service-specific clients, define timeouts in a macro: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->timeout(10) + ->connectTimeout(3) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Use Retry with Backoff for External APIs + +External APIs have transient failures. Use `retry()` with increasing delays. + +Incorrect: +```php +$response = Http::post('https://api.stripe.com/v1/charges', $data); + +if ($response->failed()) { + throw new PaymentFailedException('Charge failed'); +} +``` + +Correct: +```php +$response = Http::retry([100, 500, 1000]) + ->timeout(10) + ->post('https://api.stripe.com/v1/charges', $data); +``` + +Only retry on specific errors: + +```php +$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()); +})->post('https://api.example.com/data'); +``` + +## Handle Errors Explicitly + +The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`. + +Incorrect: +```php +$response = Http::get('https://api.example.com/users/1'); +$user = $response->json(); // Could be an error body +``` + +Correct: +```php +$response = Http::timeout(5) + ->get('https://api.example.com/users/1') + ->throw(); + +$user = $response->json(); +``` + +For graceful degradation: + +```php +$response = Http::get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Use Request Pooling for Concurrent Requests + +When making multiple independent API calls, use `Http::pool()` instead of sequential calls. + +Incorrect: +```php +$users = Http::get('https://api.example.com/users')->json(); +$posts = Http::get('https://api.example.com/posts')->json(); +$comments = Http::get('https://api.example.com/comments')->json(); +``` + +Correct: +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->get('https://api.example.com/users'), + $pool->as('posts')->get('https://api.example.com/posts'), + $pool->as('comments')->get('https://api.example.com/comments'), +]); + +$users = $responses['users']->json(); +$posts = $responses['posts']->json(); +``` + +## Fake HTTP Calls in Tests + +Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`. + +Incorrect: +```php +it('syncs user from API', function () { + $service = new UserSyncService; + $service->sync(1); // Hits the real API +}); +``` + +Correct: +```php +it('syncs user from API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + $service = new UserSyncService; + $service->sync(1); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://api.example.com/users/1'; + }); +}); +``` + +Test failure scenarios too: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/mail.md b/_api_app/.github/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 000000000..c7f67966e --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,27 @@ +# Mail Best Practices + +## Implement `ShouldQueue` on the Mailable Class + +Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it. + +## Use `afterCommit()` on Mailables Inside Transactions + +A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor. + +## Use `assertQueued()` Not `assertSent()` for Queued Mailables + +`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence. + +Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`. + +Correct: `Mail::assertQueued(OrderShipped::class);` + +## Use Markdown Mailables for Transactional Emails + +Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag. + +## Separate Content Tests from Sending Tests + +Content tests: instantiate the mailable directly, call `assertSeeInHtml()`. +Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`. +Don't mix them — it conflates concerns and makes tests brittle. \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/migrations.md b/_api_app/.github/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 000000000..de25aa39c --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,121 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Always use `php artisan make:migration` for consistent naming and timestamps. + +Incorrect (manually created file): +```php +// database/migrations/posts_migration.php ← wrong naming, no timestamp +``` + +Correct (Artisan-generated): +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Use `constrained()` for Foreign Keys + +Automatic naming and referential integrity. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); + +// Non-standard names +$table->foreignId('author_id')->constrained('users'); +``` + +## Never Modify Deployed Migrations + +Once a migration has run in production, treat it as immutable. Create a new migration to change the table. + +Incorrect (editing a deployed migration): +```php +// 2024_01_01_create_posts_table.php — already in production +$table->string('slug')->unique(); // ← added after deployment +``` + +Correct (new migration to alter): +```php +// 2024_03_15_add_slug_to_posts_table.php +Schema::table('posts', function (Blueprint $table) { + $table->string('slug')->unique()->after('title'); +}); +``` + +## Add Indexes in the Migration + +Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes. + +Incorrect: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Correct: +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->index(); + $table->string('status')->index(); + $table->timestamp('shipped_at')->nullable()->index(); + $table->timestamps(); +}); +``` + +## Mirror Defaults in Model `$attributes` + +When a column has a database default, mirror it in the model so new instances have correct values before saving. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Write Reversible `down()` Methods by Default + +Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments. + +```php +public function down(): void +{ + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('slug'); + }); +} +``` + +For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported. + +## Keep Migrations Focused + +One concern per migration. Never mix DDL (schema changes) and DML (data manipulation). + +Incorrect (partial failure creates unrecoverable state): +```php +public function up(): void +{ + Schema::create('settings', function (Blueprint $table) { ... }); + DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +} +``` + +Correct (separate migrations): +```php +// Migration 1: create_settings_table +Schema::create('settings', function (Blueprint $table) { ... }); + +// Migration 2: seed_default_settings +DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']); +``` \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/queue-jobs.md b/_api_app/.github/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 000000000..2f174dfc2 --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,146 @@ +# Queue & Job Best Practices + +## Set `retry_after` Greater Than `timeout` + +If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution. + +Incorrect (`retry_after` ≤ `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 90 ← job retried while still running! +``` + +Correct (`retry_after` > `timeout`): +```php +class ProcessReport implements ShouldQueue +{ + public $timeout = 120; +} + +// config/queue.php — retry_after: 180 ← safely longer than any job timeout +``` + +## Use Exponential Backoff + +Use progressively longer delays between retries to avoid hammering failing services. + +Incorrect (fixed retry interval): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + // Default: retries immediately, overwhelming the API +} +``` + +Correct (exponential backoff): +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 3; + public $backoff = [1, 5, 10]; +} +``` + +## Implement `ShouldBeUnique` + +Prevent duplicate job processing. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public function uniqueId(): string + { + return $this->order->id; + } + + public $uniqueFor = 3600; +} +``` + +## Always Implement `failed()` + +Handle errors explicitly — don't rely on silent failure. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]); +} +``` + +## Rate Limit External API Calls in Jobs + +Use `RateLimited` middleware to throttle jobs calling third-party APIs. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Related Jobs + +Use `Bus::batch()` when jobs should succeed or fail together. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) +->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) +->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed')) +->dispatch(); +``` + +## `retryUntil()` Needs `$tries = 0` + +When using time-based retry limits, set `$tries = 0` to avoid premature failure. + +```php +public $tries = 0; + +public function retryUntil(): \DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use `WithoutOverlapping::untilProcessing()` + +Prevents concurrent execution while allowing new instances to queue. + +```php +public function middleware(): array +{ + return [new WithoutOverlapping($this->product->id)->untilProcessing()]; +} +``` + +Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts. + +## Use Horizon for Complex Queue Scenarios + +Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities. + +```php +// config/horizon.php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['high', 'default', 'low'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], + ], +], +``` \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/routing.md b/_api_app/.github/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 000000000..e288375d7 --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,98 @@ +# Routing & Controllers Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models automatically from route parameters. + +Incorrect: +```php +public function show(int $id) +{ + $post = Post::findOrFail($id); +} +``` + +Correct: +```php +public function show(Post $post) +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Use Scoped Bindings for Nested Resources + +Enforce parent-child relationships automatically. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // $post is automatically scoped to $user +})->scopeBindings(); +``` + +## Use Resource Controllers + +Use `Route::resource()` or `apiResource()` for RESTful endpoints. + +```php +Route::resource('posts', PostController::class); +Route::apiResource('api/posts', Api\PostController::class); +``` + +## Keep Controllers Thin + +Aim for under 10 lines per method. Extract business logic to action or service classes. + +Incorrect: +```php +public function store(Request $request) +{ + $validated = $request->validate([...]); + if ($request->hasFile('image')) { + $request->file('image')->move(public_path('images')); + } + $post = Post::create($validated); + $post->tags()->sync($validated['tags']); + event(new PostCreated($post)); + return redirect()->route('posts.show', $post); +} +``` + +Correct: +```php +public function store(StorePostRequest $request, CreatePostAction $create) +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +## Type-Hint Form Requests + +Type-hinting Form Requests triggers automatic validation and authorization before the method executes. + +Incorrect: +```php +public function store(Request $request): RedirectResponse +{ + $validated = $request->validate([ + 'title' => ['required', 'max:255'], + 'body' => ['required'], + ]); + + Post::create($validated); + + return redirect()->route('posts.index'); +} +``` + +Correct: +```php +public function store(StorePostRequest $request): RedirectResponse +{ + Post::create($request->validated()); + + return redirect()->route('posts.index'); +} +``` \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/scheduling.md b/_api_app/.github/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 000000000..dfaefa26f --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,39 @@ +# Task Scheduling Best Practices + +## Use `withoutOverlapping()` on Variable-Duration Tasks + +Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion. + +## Use `onOneServer()` on Multi-Server Deployments + +Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached). + +## Use `runInBackground()` for Concurrent Long Tasks + +By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes. + +## Use `environments()` to Restrict Tasks + +Prevent accidental execution of production-only tasks (billing, reporting) on staging. + +```php +Schedule::command('billing:charge')->monthly()->environments(['production']); +``` + +## Use `takeUntilTimeout()` for Time-Bounded Processing + +A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time. + +## Use Schedule Groups for Shared Configuration + +Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/security.md b/_api_app/.github/skills/laravel-best-practices/rules/security.md new file mode 100644 index 000000000..524d47e61 --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,198 @@ +# Security Best Practices + +## Mass Assignment Protection + +Every model must define `$fillable` (whitelist) or `$guarded` (blacklist). + +Incorrect: +```php +class User extends Model +{ + protected $guarded = []; // All fields are mass assignable +} +``` + +Correct: +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Never use `$guarded = []` on models that accept user input. + +## Authorize Every Action + +Use policies or gates in controllers. Never skip authorization. + +Incorrect: +```php +public function update(Request $request, Post $post) +{ + $post->update($request->validated()); +} +``` + +Correct: +```php +public function update(UpdatePostRequest $request, Post $post) +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); +} +``` + +Or via Form Request: + +```php +public function authorize(): bool +{ + return $this->user()->can('update', $this->route('post')); +} +``` + +## Prevent SQL Injection + +Always use parameter binding. Never interpolate user input into queries. + +Incorrect: +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: +```php +User::where('name', $request->name)->get(); + +// Raw expressions with bindings +User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get(); +``` + +## Escape Output to Prevent XSS + +Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content. + +Incorrect: +```blade +{!! $user->bio !!} +``` + +Correct: +```blade +{{ $user->bio }} +``` + +## CSRF Protection + +Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia. + +Incorrect: +```blade +
+ +
+``` + +Correct: +```blade +
+ @csrf + +
+``` + +## Rate Limit Auth and API Routes + +Apply `throttle` middleware to authentication and API routes. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +## Validate File Uploads + +Validate MIME type, extension, and size. Never trust client-provided filenames. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Store with generated filenames: + +```php +$path = $request->file('avatar')->store('avatars', 'public'); +``` + +## Keep Secrets Out of Code + +Never commit `.env`. Access secrets via `config()` only. + +Incorrect: +```php +$key = env('API_KEY'); +``` + +Correct: +```php +// config/services.php +'api_key' => env('API_KEY'), + +// In application code +$key = config('services.api_key'); +``` + +## Audit Dependencies + +Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment. + +```bash +composer audit +``` + +## Encrypt Sensitive Database Fields + +Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`. + +Incorrect: +```php +class Integration extends Model +{ + protected function casts(): array + { + return [ + 'api_key' => 'string', + ]; + } +} +``` + +Correct: +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/style.md b/_api_app/.github/skills/laravel-best-practices/rules/style.md new file mode 100644 index 000000000..67af98919 --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,125 @@ +# Conventions & Style + +## Follow Laravel Naming Conventions + +| What | Convention | Good | Bad | +|------|-----------|------|-----| +| Controller | singular | `ArticleController` | `ArticlesController` | +| Model | singular | `User` | `Users` | +| Table | plural, snake_case | `article_comments` | `articleComments` | +| Pivot table | singular alphabetical | `article_user` | `user_article` | +| Column | snake_case, no model name | `meta_title` | `article_meta_title` | +| Foreign key | singular model + `_id` | `article_id` | `articles_id` | +| Route | plural | `articles/1` | `article/1` | +| Route name | snake_case with dots | `users.show_active` | `users.show-active` | +| Method | camelCase | `getAll` | `get_all` | +| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` | +| Collection | descriptive, plural | `$activeUsers` | `$data` | +| Object | descriptive, singular | `$activeUser` | `$users` | +| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` | +| Config | snake_case | `google_calendar.php` | `googleCalendar.php` | +| Enum | singular | `UserType` | `UserTypes` | + +## Prefer Shorter Readable Syntax + +| Verbose | Shorter | +|---------|---------| +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `$request->input('name')` | `$request->name` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `App::make('Class')` | `app('Class')` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()->name` | `->value('name')` | + +## Use Laravel String & Array Helpers + +Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them. + +Strings — use `Str` and fluent `Str::of()` over raw PHP: +```php +// Incorrect +$slug = strtolower(str_replace(' ', '-', $title)); +$short = substr($text, 0, 100) . '...'; +$class = substr(strrchr('App\Models\User', '\'), 1); + +// Correct +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename('App\Models\User'); +``` + +Fluent strings — chain operations for complex transformations: +```php +// Incorrect +$result = strtolower(trim(str_replace('_', '-', $input))); + +// Correct +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`. + +Arrays — use `Arr` over raw PHP: +```php +// Incorrect +$name = isset($array['user']['name']) ? $array['user']['name'] : 'default'; + +// Correct +$name = Arr::get($array, 'user.name', 'default'); +``` + +Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`. + +Numbers — use `Number` for display formatting: +```php +Number::format(1000000); // "1,000,000" +Number::currency(1500, 'USD'); // "$1,500.00" +Number::abbreviate(1000000); // "1M" +Number::fileSize(1024 * 1024); // "1 MB" +Number::percentage(75.5); // "75.5%" +``` + +URIs — use `Uri` for URL manipulation: +```php +$uri = Uri::of('https://example.com/search') + ->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining. + +Use `search-docs` for the full list of available methods — these helpers are extensive. + +## No Inline JS/CSS in Blade + +Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes. + +Incorrect: +```blade +let article = `{{ json_encode($article) }}`; +``` + +Correct: +```blade + +``` + +Pass data to JS via data attributes or use a dedicated PHP-to-JS package. + +## No Unnecessary Comments + +Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected. + +Incorrect: +```php +// Check if there are any joins +if (count((array) $builder->getQuery()->joins) > 0) +``` + +Correct: +```php +if ($this->hasJoins()) +``` \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/testing.md b/_api_app/.github/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 000000000..d39cc3ed0 --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/testing.md @@ -0,0 +1,43 @@ +# Testing Best Practices + +## Use `LazilyRefreshDatabase` Over `RefreshDatabase` + +`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites. + +## Use Model Assertions Over Raw Database Assertions + +Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);` + +Correct: `$this->assertModelExists($user);` + +More expressive, type-safe, and fails with clearer messages. + +## Use Factory States and Sequences + +Named states make tests self-documenting. Sequences eliminate repetitive setup. + +Incorrect: `User::factory()->create(['email_verified_at' => null]);` + +Correct: `User::factory()->unverified()->create();` + +## Use `Exceptions::fake()` to Assert Exception Reporting + +Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally. + +## Call `Event::fake()` After Factory Setup + +Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models. + +Incorrect: `Event::fake(); $user = User::factory()->create();` + +Correct: `$user = User::factory()->create(); Event::fake();` + +## Use `recycle()` to Share Relationship Instances Across Factories + +Without `recycle()`, nested factories create separate instances of the same conceptual entity. + +```php +Ticket::factory() + ->recycle(Airline::factory()->create()) + ->create(); +``` \ No newline at end of file diff --git a/_api_app/.github/skills/laravel-best-practices/rules/validation.md b/_api_app/.github/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 000000000..a20202ff1 --- /dev/null +++ b/_api_app/.github/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,75 @@ +# Validation & Forms Best Practices + +## Use Form Request Classes + +Extract validation from controllers into dedicated Form Request classes. + +Incorrect: +```php +public function store(Request $request) +{ + $request->validate([ + 'title' => 'required|max:255', + 'body' => 'required', + ]); +} +``` + +Correct: +```php +public function store(StorePostRequest $request) +{ + Post::create($request->validated()); +} +``` + +## Array vs. String Notation for Rules + +Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses. + +```php +// Preferred for new code +'email' => ['required', 'email', Rule::unique('users')], + +// Follow existing convention if the project uses string notation +'email' => 'required|email|unique:users', +``` + +## Always Use `validated()` + +Get only validated data. Never use `$request->all()` for mass operations. + +Incorrect: +```php +Post::create($request->all()); +``` + +Correct: +```php +Post::create($request->validated()); +``` + +## Use `Rule::when()` for Conditional Validation + +```php +'company_name' => [ + Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']), +], +``` + +## Use the `after()` Method for Custom Validation + +Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($this->quantity > Product::find($this->product_id)?->stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` \ No newline at end of file diff --git a/_api_app/.github/skills/pest-testing/SKILL.md b/_api_app/.github/skills/pest-testing/SKILL.md index f6973277f..0761b6e76 100644 --- a/_api_app/.github/skills/pest-testing/SKILL.md +++ b/_api_app/.github/skills/pest-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: pest-testing -description: "Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works." +description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit) or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 3 features. Do not use for editing factories, seeders, migrations, controllers, models, or non-test PHP code." license: MIT metadata: author: laravel @@ -8,15 +8,6 @@ metadata: # Pest Testing 3 -## When to Apply - -Activate this skill when: -- Creating new tests (unit or feature) -- Modifying existing tests -- Debugging test failures -- Working with datasets, mocking, or test organization -- Writing architecture tests - ## Documentation Use `search-docs` for detailed Pest 3 patterns and documentation. @@ -35,6 +26,8 @@ All tests must be written using Pest. Use `php artisan make:test --pest {name}`. ### Basic Test Structure +Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`. + ```php it('is true', function () { diff --git a/_api_app/AGENTS.md b/_api_app/AGENTS.md index 23eb4ca32..29ac7c226 100644 --- a/_api_app/AGENTS.md +++ b/_api_app/AGENTS.md @@ -9,7 +9,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. -- php - 8.3.29 +- php - 8.4 - laravel/ai (AI) - v0 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 @@ -25,8 +25,9 @@ This application is a Laravel application and its main Laravel ecosystems packag This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. -- `pest-testing` — Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works. - `ai-sdk-development` — Builds AI agents, generates text and chat responses, produces images, synthesizes audio, transcribes speech, generates vector embeddings, reranks documents, and manages files and vector stores using the Laravel AI SDK (laravel/ai). Supports structured output, streaming, tools, conversation memory, middleware, queueing, broadcasting, and provider failover. Use when building, editing, updating, debugging, or testing any AI functionality, including agents, LLMs, chatbots, text generation, image generation, audio, transcription, embeddings, RAG, similarity search, vector stores, prompting, structured output, or any AI provider (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). +- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns. +- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit) or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 3 features. Do not use for editing factories, seeders, migrations, controllers, models, or non-test PHP code. ## Conventions @@ -59,82 +60,51 @@ This project has domain-specific skills available. You MUST activate the relevan # Laravel Boost -- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. +## Tools -## Artisan Commands +- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads. +- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker. +- Use `database-schema` to inspect table structure before writing migrations or models. +- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. +- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries. -- Run Artisan commands directly via the command line (e.g., `php artisan route:list`, `php artisan tinker --execute "..."`). -- Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +## Searching Documentation (IMPORTANT) -## URLs +- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically. +- Pass a `packages` array to scope results when you know which packages are relevant. +- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first. +- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`. -- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port. +### Search Syntax -## Debugging +1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit". +2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order. +3. Combine words and phrases for mixed queries: `middleware "rate limit"`. +4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`. -- Use the `database-query` tool when you only need to read from the database. -- Use the `database-schema` tool to inspect table structure before writing migrations or models. -- To execute PHP code for debugging, run `php artisan tinker --execute "your code here"` directly. -- To read configuration values, read the config files directly or run `php artisan config:show [key]`. -- To inspect routes, run `php artisan route:list` directly. -- To check environment variables, read the `.env` file directly. - -## Reading Browser Logs With the `browser-logs` Tool - -- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. -- Only recent browser logs will be useful - ignore old logs. +## Artisan -## Searching Documentation (Critically Important) - -- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. -- Search the documentation before making code changes to ensure we are taking the correct approach. -- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first. -- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. +- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. +- To check environment variables, read the `.env` file directly. -### Available Search Syntax +## Tinker -1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'. -2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit". -3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order. -4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit". -5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms. +- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. +- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` === php rules === # PHP - Always use curly braces for control structures, even for single-line bodies. - -## Constructors - -- Use PHP 8 constructor property promotion in `__construct()`. - - `public function __construct(public GitHub $github) { }` -- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private. - -## Type Declarations - -- Always use explicit return type declarations for methods and functions. -- Use appropriate PHP type hints for method parameters. - - -```php -protected function isAccessible(User $user, ?string $path = null): bool -{ - ... -} -``` - -## Enums - -- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. - -## Comments - -- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex. - -## PHPDoc Blocks - -- Add useful array shape type definitions when appropriate. +- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. +- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` +- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. +- Use array shape type definitions in PHPDoc blocks. === tests rules === @@ -143,6 +113,13 @@ protected function isAccessible(User $user, ?string $path = null): bool - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. +=== ai/core rules === + +## Laravel AI SDK + +- This application uses the Laravel AI SDK (`laravel/ai`) for all AI functionality. +- Activate the `developing-with-ai-sdk` skill when building, editing, updating, debugging, or testing AI agents, text generation, chat, streaming, structured output, tools, image generation, audio, transcription, embeddings, reranking, vector stores, files, conversation memory, or any AI provider integration (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). + === laravel/core rules === # Do Things the Laravel Way @@ -151,43 +128,18 @@ protected function isAccessible(User $user, ?string $path = null): bool - If you're creating a generic PHP class, use `php artisan make:class`. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. -## Database - -- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. -- Use Eloquent models and relationships before suggesting raw database queries. -- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. -- Generate code that prevents N+1 query problems by using eager loading. -- Use Laravel's query builder for very complex database operations. - ### Model Creation - When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options. -### APIs & Eloquent Resources +## APIs & Eloquent Resources - For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. -## Controllers & Validation - -- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. -- Check sibling Form Requests to see if the application uses array or string based validation rules. - -## Authentication & Authorization - -- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). - ## URL Generation - When generating links to other pages, prefer named routes and the `route()` function. -## Queues - -- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. - -## Configuration - -- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. - ## Testing - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. @@ -198,6 +150,10 @@ protected function isAccessible(User $user, ?string $path = null): bool - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. +## Deployment + +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. + === laravel/v12 rules === # Laravel 12 @@ -211,7 +167,7 @@ protected function isAccessible(User $user, ?string $path = null): bool - Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/providers.php` contains application specific service providers. -- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- The `app/Console/Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. - Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. ## Database @@ -237,14 +193,5 @@ protected function isAccessible(User $user, ?string $path = null): bool - This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. - Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. - Do NOT delete tests without approval. -- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. -- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. - -=== laravel/ai rules === - -## Laravel AI SDK - -- This application uses the Laravel AI SDK (`laravel/ai`) for all AI functionality. -- Activate the `developing-with-ai-sdk` skill when building, editing, updating, debugging, or testing AI agents, text generation, chat, streaming, structured output, tools, image generation, audio, transcription, embeddings, reranking, vector stores, files, conversation memory, or any AI provider integration (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). diff --git a/_api_app/CLAUDE.md b/_api_app/CLAUDE.md index 23eb4ca32..29ac7c226 100644 --- a/_api_app/CLAUDE.md +++ b/_api_app/CLAUDE.md @@ -9,7 +9,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. -- php - 8.3.29 +- php - 8.4 - laravel/ai (AI) - v0 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 @@ -25,8 +25,9 @@ This application is a Laravel application and its main Laravel ecosystems packag This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. -- `pest-testing` — Tests applications using the Pest 3 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, architecture testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works. - `ai-sdk-development` — Builds AI agents, generates text and chat responses, produces images, synthesizes audio, transcribes speech, generates vector embeddings, reranks documents, and manages files and vector stores using the Laravel AI SDK (laravel/ai). Supports structured output, streaming, tools, conversation memory, middleware, queueing, broadcasting, and provider failover. Use when building, editing, updating, debugging, or testing any AI functionality, including agents, LLMs, chatbots, text generation, image generation, audio, transcription, embeddings, RAG, similarity search, vector stores, prompting, structured output, or any AI provider (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). +- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns. +- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit) or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 3 features. Do not use for editing factories, seeders, migrations, controllers, models, or non-test PHP code. ## Conventions @@ -59,82 +60,51 @@ This project has domain-specific skills available. You MUST activate the relevan # Laravel Boost -- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. +## Tools -## Artisan Commands +- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads. +- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker. +- Use `database-schema` to inspect table structure before writing migrations or models. +- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. +- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries. -- Run Artisan commands directly via the command line (e.g., `php artisan route:list`, `php artisan tinker --execute "..."`). -- Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +## Searching Documentation (IMPORTANT) -## URLs +- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically. +- Pass a `packages` array to scope results when you know which packages are relevant. +- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first. +- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`. -- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port. +### Search Syntax -## Debugging +1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit". +2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order. +3. Combine words and phrases for mixed queries: `middleware "rate limit"`. +4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`. -- Use the `database-query` tool when you only need to read from the database. -- Use the `database-schema` tool to inspect table structure before writing migrations or models. -- To execute PHP code for debugging, run `php artisan tinker --execute "your code here"` directly. -- To read configuration values, read the config files directly or run `php artisan config:show [key]`. -- To inspect routes, run `php artisan route:list` directly. -- To check environment variables, read the `.env` file directly. - -## Reading Browser Logs With the `browser-logs` Tool - -- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. -- Only recent browser logs will be useful - ignore old logs. +## Artisan -## Searching Documentation (Critically Important) - -- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. -- Search the documentation before making code changes to ensure we are taking the correct approach. -- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first. -- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. +- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. +- To check environment variables, read the `.env` file directly. -### Available Search Syntax +## Tinker -1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'. -2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit". -3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order. -4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit". -5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms. +- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. +- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` === php rules === # PHP - Always use curly braces for control structures, even for single-line bodies. - -## Constructors - -- Use PHP 8 constructor property promotion in `__construct()`. - - `public function __construct(public GitHub $github) { }` -- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private. - -## Type Declarations - -- Always use explicit return type declarations for methods and functions. -- Use appropriate PHP type hints for method parameters. - - -```php -protected function isAccessible(User $user, ?string $path = null): bool -{ - ... -} -``` - -## Enums - -- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. - -## Comments - -- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex. - -## PHPDoc Blocks - -- Add useful array shape type definitions when appropriate. +- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. +- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` +- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. +- Use array shape type definitions in PHPDoc blocks. === tests rules === @@ -143,6 +113,13 @@ protected function isAccessible(User $user, ?string $path = null): bool - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. +=== ai/core rules === + +## Laravel AI SDK + +- This application uses the Laravel AI SDK (`laravel/ai`) for all AI functionality. +- Activate the `developing-with-ai-sdk` skill when building, editing, updating, debugging, or testing AI agents, text generation, chat, streaming, structured output, tools, image generation, audio, transcription, embeddings, reranking, vector stores, files, conversation memory, or any AI provider integration (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). + === laravel/core rules === # Do Things the Laravel Way @@ -151,43 +128,18 @@ protected function isAccessible(User $user, ?string $path = null): bool - If you're creating a generic PHP class, use `php artisan make:class`. - Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. -## Database - -- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. -- Use Eloquent models and relationships before suggesting raw database queries. -- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. -- Generate code that prevents N+1 query problems by using eager loading. -- Use Laravel's query builder for very complex database operations. - ### Model Creation - When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options. -### APIs & Eloquent Resources +## APIs & Eloquent Resources - For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. -## Controllers & Validation - -- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. -- Check sibling Form Requests to see if the application uses array or string based validation rules. - -## Authentication & Authorization - -- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). - ## URL Generation - When generating links to other pages, prefer named routes and the `route()` function. -## Queues - -- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. - -## Configuration - -- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. - ## Testing - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. @@ -198,6 +150,10 @@ protected function isAccessible(User $user, ?string $path = null): bool - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. +## Deployment + +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. + === laravel/v12 rules === # Laravel 12 @@ -211,7 +167,7 @@ protected function isAccessible(User $user, ?string $path = null): bool - Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/providers.php` contains application specific service providers. -- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- The `app/Console/Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. - Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. ## Database @@ -237,14 +193,5 @@ protected function isAccessible(User $user, ?string $path = null): bool - This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. - Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. - Do NOT delete tests without approval. -- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. -- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. - -=== laravel/ai rules === - -## Laravel AI SDK - -- This application uses the Laravel AI SDK (`laravel/ai`) for all AI functionality. -- Activate the `developing-with-ai-sdk` skill when building, editing, updating, debugging, or testing AI agents, text generation, chat, streaming, structured output, tools, image generation, audio, transcription, embeddings, reranking, vector stores, files, conversation memory, or any AI provider integration (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). diff --git a/_api_app/boost.json b/_api_app/boost.json index 1a418e377..1ef53754c 100644 --- a/_api_app/boost.json +++ b/_api_app/boost.json @@ -10,12 +10,10 @@ "herd_mcp": true, "mcp": true, "nightwatch_mcp": false, - "packages": [ - "laravel/ai" - ], "sail": false, "skills": [ - "pest-testing", - "ai-sdk-development" + "ai-sdk-development", + "laravel-best-practices", + "pest-testing" ] } diff --git a/_api_app/composer.lock b/_api_app/composer.lock index a349c39f6..43172b1b2 100644 --- a/_api_app/composer.lock +++ b/_api_app/composer.lock @@ -1610,16 +1610,16 @@ }, { "name": "laravel/prompts", - "version": "v0.3.14", + "version": "v0.3.16", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "9f0e371244eedfe2ebeaa72c79c54bb5df6e0176" + "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/9f0e371244eedfe2ebeaa72c79c54bb5df6e0176", - "reference": "9f0e371244eedfe2ebeaa72c79c54bb5df6e0176", + "url": "https://api.github.com/repos/laravel/prompts/zipball/11e7d5f93803a2190b00e145142cb00a33d17ad2", + "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2", "shasum": "" }, "require": { @@ -1663,9 +1663,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.14" + "source": "https://github.com/laravel/prompts/tree/v0.3.16" }, - "time": "2026-03-01T09:02:38+00:00" + "time": "2026-03-23T14:35:33+00:00" }, { "name": "laravel/sanctum", @@ -4435,16 +4435,16 @@ }, { "name": "symfony/console", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" + "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "url": "https://api.github.com/repos/symfony/console/zipball/1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", + "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", "shasum": "" }, "require": { @@ -4509,7 +4509,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.7" + "source": "https://github.com/symfony/console/tree/v7.4.8" }, "funding": [ { @@ -4529,7 +4529,7 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:20+00:00" + "time": "2026-03-30T13:54:39+00:00" }, { "name": "symfony/css-selector", @@ -6574,35 +6574,34 @@ }, { "name": "symfony/string", - "version": "v7.4.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "9f209231affa85aa930a5e46e6eb03381424b30b" + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/9f209231affa85aa930a5e46e6eb03381424b30b", - "reference": "9f209231affa85aa930a5e46e6eb03381424b30b", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.33", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0" + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -6641,7 +6640,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.6" + "source": "https://github.com/symfony/string/tree/v8.0.8" }, "funding": [ { @@ -6661,7 +6660,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T09:33:46+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/translation", @@ -7693,16 +7692,16 @@ }, { "name": "laravel/boost", - "version": "v2.3.1", + "version": "v2.4.2", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "ba0a9e6497398b6ce8243f5517b67d6761509150" + "reference": "74fedf18048d382e04eded004abe2ad1ee1d9a97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/ba0a9e6497398b6ce8243f5517b67d6761509150", - "reference": "ba0a9e6497398b6ce8243f5517b67d6761509150", + "url": "https://api.github.com/repos/laravel/boost/zipball/74fedf18048d382e04eded004abe2ad1ee1d9a97", + "reference": "74fedf18048d382e04eded004abe2ad1ee1d9a97", "shasum": "" }, "require": { @@ -7755,20 +7754,20 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-03-12T09:06:47+00:00" + "time": "2026-04-07T14:34:49+00:00" }, { "name": "laravel/mcp", - "version": "v0.6.2", + "version": "v0.6.5", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "f696e44735b95ff275392eab8ce5a3b4b42a2223" + "reference": "583a6282bf0f074d754f7ff5cd1fff9d34244691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/f696e44735b95ff275392eab8ce5a3b4b42a2223", - "reference": "f696e44735b95ff275392eab8ce5a3b4b42a2223", + "url": "https://api.github.com/repos/laravel/mcp/zipball/583a6282bf0f074d754f7ff5cd1fff9d34244691", + "reference": "583a6282bf0f074d754f7ff5cd1fff9d34244691", "shasum": "" }, "require": { @@ -7828,7 +7827,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2026-03-10T20:00:23+00:00" + "time": "2026-03-30T19:17:10+00:00" }, { "name": "laravel/pint", @@ -10486,16 +10485,16 @@ }, { "name": "symfony/yaml", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "58751048de17bae71c5aa0d13cb19d79bca26391" + "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391", - "reference": "58751048de17bae71c5aa0d13cb19d79bca26391", + "url": "https://api.github.com/repos/symfony/yaml/zipball/c58fdf7b3d6c2995368264c49e4e8b05bcff2883", + "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883", "shasum": "" }, "require": { @@ -10538,7 +10537,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.6" + "source": "https://github.com/symfony/yaml/tree/v7.4.8" }, "funding": [ { @@ -10558,7 +10557,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T09:33:46+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", From 3d43e85d8614bc2a66dd4708dc5c648c19fb045f Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Fri, 10 Apr 2026 12:55:05 +0300 Subject: [PATCH 11/21] Upgrade to PHP 8.4 --- .github/workflows/tests.yml | 2 +- CLAUDE.md | 29 ++++++++++--------- INSTALL/includes/check.php | 4 +-- INSTALL/includes/first_visit_serverreqs.php | 2 +- _api_app/.cursor/rules/laravel-boost.mdc | 2 +- _api_app/.github/copilot-instructions.md | 2 +- _api_app/.junie/guidelines.md | 2 +- _api_app/app/Http/Middleware/Authenticate.php | 3 +- .../Entries/SectionEntriesController.php | 2 +- .../Sections/SectionsMenuRenderService.php | 2 +- .../Sections/SiteSectionsDataService.php | 10 +++---- .../Sites/Sections/SitemapRenderService.php | 2 +- .../Sections/Tags/SectionTagsDataService.php | 2 +- .../Settings/SiteSettingsDataService.php | 2 +- _api_app/app/Sites/SitesDataService.php | 2 +- .../SiteTemplateSettingsDataService.php | 2 +- _api_app/composer.json | 2 +- engine/inc.page.php | 4 +-- 18 files changed, 39 insertions(+), 37 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ecd859362..83facd96d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.3 + php-version: 8.4 tools: composer:v2 coverage: xdebug diff --git a/CLAUDE.md b/CLAUDE.md index 13a976ffa..4178fbfa8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,13 +6,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Berta is a file-based CMS (no database required for content storage). It has three distinct sub-applications that are developed and built independently: -1. **`_api_app/`** — Laravel 12 API backend (PHP 8.2+) +1. **`_api_app/`** — Laravel 12 API backend (PHP 8.4+) 2. **`editor/`** — Angular 20 admin editor (TypeScript) 3. **`engine/`** — Legacy PHP rendering engine with Gulp-built assets ## Development Commands ### Laravel API (`_api_app/`) + ```bash cd _api_app composer install @@ -24,6 +25,7 @@ npm run build # Build assets ``` ### Angular Editor (`editor/`) + ```bash cd editor npm install @@ -33,6 +35,7 @@ npm test # Karma/Jasmine unit tests ``` ### Legacy Engine Assets (root) + ```bash npm install npm run dev # Gulp watch (compiles Sass for themes/templates) @@ -50,18 +53,18 @@ npm run build # Gulp production build ### Key Directories -| Path | Purpose | -|------|---------| -| `_api_app/app/Sites/` | Site/section/entry management domain | -| `_api_app/app/Shop/` | E-commerce plugin | -| `_api_app/app/Plugins/` | Plugin system | -| `_api_app/app/Configuration/` | App configuration classes | -| `editor/src/` | Angular source (components, state, services) | -| `engine/_classes/` | Legacy PHP classes for site rendering | -| `engine/_lib/berta/` | CSS/JS assets bundled by Gulp | -| `_themes/` | Site themes (capetown, jaipur, kyoto, madrid, etc.) | -| `_templates/` | Email/system templates with SCSS | -| `_plugin_shop/` | Shop plugin PHP files | +| Path | Purpose | +| ----------------------------- | --------------------------------------------------- | +| `_api_app/app/Sites/` | Site/section/entry management domain | +| `_api_app/app/Shop/` | E-commerce plugin | +| `_api_app/app/Plugins/` | Plugin system | +| `_api_app/app/Configuration/` | App configuration classes | +| `editor/src/` | Angular source (components, state, services) | +| `engine/_classes/` | Legacy PHP classes for site rendering | +| `engine/_lib/berta/` | CSS/JS assets bundled by Gulp | +| `_themes/` | Site themes (capetown, jaipur, kyoto, madrid, etc.) | +| `_templates/` | Email/system templates with SCSS | +| `_plugin_shop/` | Shop plugin PHP files | ### State Management (Angular) diff --git a/INSTALL/includes/check.php b/INSTALL/includes/check.php index 3adf9e7a4..4e3a5fe1a 100644 --- a/INSTALL/includes/check.php +++ b/INSTALL/includes/check.php @@ -25,10 +25,10 @@ function getStatus($isOk, $message, $failDesc = '', $isIndented = false, $isFata $testOutput .= '

Is your website hosted on a suitable server?

'; // php version ... - $isOk = version_compare(PHP_VERSION, '8.2', '>='); + $isOk = version_compare(PHP_VERSION, '8.4', '>='); $listOk &= $isOk; $listHasErrors |= ! $isOk; - $testOutput .= getStatus($isOk, 'Supported PHP version', 'Berta needs PHP >= 8.2 support on server. Ask your server administrator to enable supported PHP version.'); + $testOutput .= getStatus($isOk, 'Supported PHP version', 'Berta needs PHP >= 8.4 support on server. Ask your server administrator to enable supported PHP version.'); // multibyte ... $isOk = function_exists('mb_ereg_replace') && function_exists('mb_strlen') && function_exists('mb_substr'); diff --git a/INSTALL/includes/first_visit_serverreqs.php b/INSTALL/includes/first_visit_serverreqs.php index 6c96f4705..d663883bf 100644 --- a/INSTALL/includes/first_visit_serverreqs.php +++ b/INSTALL/includes/first_visit_serverreqs.php @@ -63,7 +63,7 @@

Thank you for choosing Berta.me!

This server does not meet Berta's requirements.
- Berta needs PHP >= 8.2 support on server.

+ Berta needs PHP >= 8.4 support on server.

diff --git a/_api_app/.cursor/rules/laravel-boost.mdc b/_api_app/.cursor/rules/laravel-boost.mdc index 6e610c09a..f86798313 100644 --- a/_api_app/.cursor/rules/laravel-boost.mdc +++ b/_api_app/.cursor/rules/laravel-boost.mdc @@ -11,7 +11,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for ## Foundational Context This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. -- php - 8.2.29 +- php - 8.4 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 - laravel/sanctum (SANCTUM) - v4 diff --git a/_api_app/.github/copilot-instructions.md b/_api_app/.github/copilot-instructions.md index c3ec68551..b29a8dac2 100644 --- a/_api_app/.github/copilot-instructions.md +++ b/_api_app/.github/copilot-instructions.md @@ -8,7 +8,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for ## Foundational Context This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. -- php - 8.2.29 +- php - 8.4 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 - laravel/sanctum (SANCTUM) - v4 diff --git a/_api_app/.junie/guidelines.md b/_api_app/.junie/guidelines.md index c3ec68551..b29a8dac2 100644 --- a/_api_app/.junie/guidelines.md +++ b/_api_app/.junie/guidelines.md @@ -8,7 +8,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for ## Foundational Context This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. -- php - 8.2.29 +- php - 8.4 - laravel/framework (LARAVEL) - v12 - laravel/prompts (PROMPTS) - v0 - laravel/sanctum (SANCTUM) - v4 diff --git a/_api_app/app/Http/Middleware/Authenticate.php b/_api_app/app/Http/Middleware/Authenticate.php index ca43c51f9..1e5bb8cdf 100644 --- a/_api_app/app/Http/Middleware/Authenticate.php +++ b/_api_app/app/Http/Middleware/Authenticate.php @@ -23,10 +23,9 @@ public function __construct(Auth $auth) /** * Handle an incoming request. * - * @param string|null $guard * @return mixed */ - public function handle(Request $request, Closure $next, $guard = null) + public function handle(Request $request, Closure $next, ?string $guard = null) { $apiPrefix = config('app.api_prefix'); $isAPIRequest = strpos($request->getRequestUri(), '/' . $apiPrefix) === 0; diff --git a/_api_app/app/Sites/Sections/Entries/SectionEntriesController.php b/_api_app/app/Sites/Sections/Entries/SectionEntriesController.php index cc72d5b7d..0bfe0d665 100644 --- a/_api_app/app/Sites/Sections/Entries/SectionEntriesController.php +++ b/_api_app/app/Sites/Sections/Entries/SectionEntriesController.php @@ -145,7 +145,7 @@ public function galleryCrop(Request $request) /** * This method is entry rendering example */ - public function renderEntries($site, $section, Request $request, $id = null) + public function renderEntries(string $site, string $section, Request $request, ?string $id = null) { $sectionEntriesDS = new SectionEntriesDataService($site, $section); $siteSectionsDS = new SiteSectionsDataService($site); diff --git a/_api_app/app/Sites/Sections/SectionsMenuRenderService.php b/_api_app/app/Sites/Sections/SectionsMenuRenderService.php index 4c658e13d..bba7af655 100644 --- a/_api_app/app/Sites/Sections/SectionsMenuRenderService.php +++ b/_api_app/app/Sites/Sections/SectionsMenuRenderService.php @@ -176,7 +176,7 @@ private function getViewData( ]; } - private function getUrl($section, $site, $sections, $siteSettings, $isEditMode, $isPreviewMode, $tag = null) + private function getUrl($section, $site, $sections, $siteSettings, $isEditMode, $isPreviewMode, ?string $tag = null) { $urlParts = []; $isExternalLink = isset($section['@attributes']['type']) && $section['@attributes']['type'] == 'external_link'; diff --git a/_api_app/app/Sites/Sections/SiteSectionsDataService.php b/_api_app/app/Sites/Sections/SiteSectionsDataService.php index b627d615a..fbf47fc32 100644 --- a/_api_app/app/Sites/Sections/SiteSectionsDataService.php +++ b/_api_app/app/Sites/Sections/SiteSectionsDataService.php @@ -144,7 +144,7 @@ class SiteSectionsDataService extends Storage private $isPreview; - public function __construct($site = '', $xml_root = null, $isPreview = false) + public function __construct(string $site = '', ?string $xml_root = null, bool $isPreview = false) { parent::__construct($site, $isPreview); $this->site_name = $site; @@ -158,7 +158,7 @@ public function __construct($site = '', $xml_root = null, $isPreview = false) * * @return array Array of sections */ - public function get($sectionName = null) + public function get(?string $sectionName = null) { if (! $this->SECTIONS) { $this->SECTIONS = $this->xmlFile2array($this->XML_FILE); @@ -211,9 +211,9 @@ public function getState() return $sections; } - public function create($name = null, $title = null) + public function create(?string $name = null, ?string $title = null) { - if (!$name && $title) { + if (! $name && $title) { $name = $this->getUniqueSlug($title); } @@ -246,7 +246,7 @@ public function create($name = null, $title = null) return $section; } - public function cloneSection($name = null, $title = null) + public function cloneSection(?string $name = null, ?string $title = null) { $sections = $this->get(); $title = empty($title) ? $name : $title; diff --git a/_api_app/app/Sites/Sections/SitemapRenderService.php b/_api_app/app/Sites/Sections/SitemapRenderService.php index de2e298f7..3ab93b9ee 100644 --- a/_api_app/app/Sites/Sections/SitemapRenderService.php +++ b/_api_app/app/Sites/Sections/SitemapRenderService.php @@ -21,7 +21,7 @@ private function getTags($sectionTags) return $tags; } - private function getUrl($section, $siteSlug, $sections, $request, $tag = null) + private function getUrl($section, $siteSlug, $sections, $request, ?string $tag = null) { $urlParts = []; diff --git a/_api_app/app/Sites/Sections/Tags/SectionTagsDataService.php b/_api_app/app/Sites/Sections/Tags/SectionTagsDataService.php index 9dcfa9b4a..17d1271bc 100644 --- a/_api_app/app/Sites/Sections/Tags/SectionTagsDataService.php +++ b/_api_app/app/Sites/Sections/Tags/SectionTagsDataService.php @@ -117,7 +117,7 @@ class SectionTagsDataService extends Storage private $TAGS; - public function __construct($site = '', $sectionName = '', $xml_root = null) + public function __construct(string $site = '', string $sectionName = '', ?string $xml_root = null) { parent::__construct($site); $this->XML_ROOT = $xml_root ? $xml_root : $this->getSiteXmlRoot($site); diff --git a/_api_app/app/Sites/Settings/SiteSettingsDataService.php b/_api_app/app/Sites/Settings/SiteSettingsDataService.php index 458f4427e..61c9cadf5 100644 --- a/_api_app/app/Sites/Settings/SiteSettingsDataService.php +++ b/_api_app/app/Sites/Settings/SiteSettingsDataService.php @@ -292,7 +292,7 @@ class SiteSettingsDataService extends Storage private $siteTemplatesConfigService; - public function __construct($site = '', $xml_root = null) + public function __construct(string $site = '', ?string $xml_root = null) { parent::__construct($site); $xml_root = $xml_root ? $xml_root : $this->getSiteXmlRoot($site); diff --git a/_api_app/app/Sites/SitesDataService.php b/_api_app/app/Sites/SitesDataService.php index 86e856f89..f5f1b7090 100644 --- a/_api_app/app/Sites/SitesDataService.php +++ b/_api_app/app/Sites/SitesDataService.php @@ -132,7 +132,7 @@ public function getState() return $sites; } - public function create(Request $request, $cloneFrom = null) + public function create(Request $request, ?string $cloneFrom = null) { $sites = $this->get(); $name = 'untitled-' . uniqid(); diff --git a/_api_app/app/Sites/TemplateSettings/SiteTemplateSettingsDataService.php b/_api_app/app/Sites/TemplateSettings/SiteTemplateSettingsDataService.php index d783b77cf..17b4803e0 100644 --- a/_api_app/app/Sites/TemplateSettings/SiteTemplateSettingsDataService.php +++ b/_api_app/app/Sites/TemplateSettings/SiteTemplateSettingsDataService.php @@ -370,7 +370,7 @@ class SiteTemplateSettingsDataService extends Storage private $siteTemplateDefaults; - public function __construct($site = '', $template = 'messy-0.4.2', $xml_root = null) + public function __construct(string $site = '', string $template = 'messy-0.4.2', ?string $xml_root = null) { parent::__construct($site); $this->xml_root = $xml_root ? $xml_root : $this->getSiteXmlRoot($site); diff --git a/_api_app/composer.json b/_api_app/composer.json index 4789ffe3e..f5bff0936 100644 --- a/_api_app/composer.json +++ b/_api_app/composer.json @@ -11,7 +11,7 @@ ], "license": "MIT", "require": { - "php": "^8.3", + "php": "^8.4", "firebase/php-jwt": "^6.11", "intervention/image": "^3.11", "laravel/ai": "^0.3.0", diff --git a/engine/inc.page.php b/engine/inc.page.php index 30d37260f..697671745 100644 --- a/engine/inc.page.php +++ b/engine/inc.page.php @@ -60,14 +60,14 @@ $ENGINE_ROOT_URL = $SITE_ROOT_URL . 'engine/'; } -$hasSupportedPhpVersion = version_compare(PHP_VERSION, '8.2', '>='); +$hasSupportedPhpVersion = version_compare(PHP_VERSION, '8.4', '>='); if (! $hasSupportedPhpVersion) { if (file_exists($SITE_ROOT_PATH . 'INSTALL/includes/first_visit_serverreqs.php')) { $CHECK_INCLUDED = true; include $SITE_ROOT_PATH . 'INSTALL/includes/first_visit_serverreqs.php'; } else { - exit('Berta needs PHP >= 8.2 support on server.'); + exit('Berta needs PHP >= 8.4 support on server.'); } } From e9ce42d974de2226e65faa6b41af66c5de6de017 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Fri, 10 Apr 2026 13:03:26 +0300 Subject: [PATCH 12/21] Upgrade backend dependencies --- _api_app/composer.json | 2 +- _api_app/composer.lock | 946 ++++++++++++++++++++--------------------- 2 files changed, 457 insertions(+), 491 deletions(-) diff --git a/_api_app/composer.json b/_api_app/composer.json index f5bff0936..2e559503e 100644 --- a/_api_app/composer.json +++ b/_api_app/composer.json @@ -12,7 +12,7 @@ "license": "MIT", "require": { "php": "^8.4", - "firebase/php-jwt": "^6.11", + "firebase/php-jwt": "^7.0", "intervention/image": "^3.11", "laravel/ai": "^0.3.0", "laravel/framework": "^12.0", diff --git a/_api_app/composer.lock b/_api_app/composer.lock index 43172b1b2..5aefa9f62 100644 --- a/_api_app/composer.lock +++ b/_api_app/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bf128b21d160d0f62fb6a5b5b49b9a56", + "content-hash": "f4e9ffc8ab14f7b87eda359a59a247c0", "packages": [ { "name": "brick/math", @@ -510,16 +510,16 @@ }, { "name": "firebase/php-jwt", - "version": "v6.11.1", + "version": "v7.0.5", "source": { "type": "git", - "url": "https://github.com/firebase/php-jwt.git", - "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", - "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", "shasum": "" }, "require": { @@ -527,6 +527,7 @@ }, "require-dev": { "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", "phpspec/prophecy-phpunit": "^2.0", "phpunit/phpunit": "^9.5", "psr/cache": "^2.0||^3.0", @@ -566,10 +567,10 @@ "php" ], "support": { - "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" }, - "time": "2025-04-09T20:32:01+00:00" + "time": "2026-04-01T20:38:03+00:00" }, { "name": "fruitcake/php-cors", @@ -1118,16 +1119,16 @@ }, { "name": "intervention/gif", - "version": "4.2.2", + "version": "4.2.4", "source": { "type": "git", "url": "https://github.com/Intervention/gif.git", - "reference": "5999eac6a39aa760fb803bc809e8909ee67b451a" + "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/gif/zipball/5999eac6a39aa760fb803bc809e8909ee67b451a", - "reference": "5999eac6a39aa760fb803bc809e8909ee67b451a", + "url": "https://api.github.com/repos/Intervention/gif/zipball/c3598a16ebe7690cd55640c44144a9df383ea73c", + "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c", "shasum": "" }, "require": { @@ -1166,7 +1167,7 @@ ], "support": { "issues": "https://github.com/Intervention/gif/issues", - "source": "https://github.com/Intervention/gif/tree/4.2.2" + "source": "https://github.com/Intervention/gif/tree/4.2.4" }, "funding": [ { @@ -1182,20 +1183,20 @@ "type": "ko_fi" } ], - "time": "2025-03-29T07:46:21+00:00" + "time": "2026-01-04T09:27:23+00:00" }, { "name": "intervention/image", - "version": "3.11.4", + "version": "3.11.7", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "8c49eb21a6d2572532d1bc425964264f3e496846" + "reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/8c49eb21a6d2572532d1bc425964264f3e496846", - "reference": "8c49eb21a6d2572532d1bc425964264f3e496846", + "url": "https://api.github.com/repos/Intervention/image/zipball/2159bcccff18f09d2a392679b81a82c5a003f9bb", + "reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb", "shasum": "" }, "require": { @@ -1227,11 +1228,11 @@ { "name": "Oliver Vogel", "email": "oliver@intervention.io", - "homepage": "https://intervention.io/" + "homepage": "https://intervention.io" } ], - "description": "PHP image manipulation", - "homepage": "https://image.intervention.io/", + "description": "PHP Image Processing", + "homepage": "https://image.intervention.io", "keywords": [ "gd", "image", @@ -1242,7 +1243,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.11.4" + "source": "https://github.com/Intervention/image/tree/3.11.7" }, "funding": [ { @@ -1258,7 +1259,7 @@ "type": "ko_fi" } ], - "time": "2025-07-30T13:13:19+00:00" + "time": "2026-02-19T13:11:17+00:00" }, { "name": "jean85/pretty-package-versions", @@ -1322,16 +1323,16 @@ }, { "name": "laravel/ai", - "version": "v0.3.0", + "version": "v0.3.2", "source": { "type": "git", "url": "https://github.com/laravel/ai.git", - "reference": "83782933abebaa9cb0e2ccaf84b867252f1a031c" + "reference": "dfdf853427eb9fb8d763f8d9e2ed62cfcc03fb9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/ai/zipball/83782933abebaa9cb0e2ccaf84b867252f1a031c", - "reference": "83782933abebaa9cb0e2ccaf84b867252f1a031c", + "url": "https://api.github.com/repos/laravel/ai/zipball/dfdf853427eb9fb8d763f8d9e2ed62cfcc03fb9c", + "reference": "dfdf853427eb9fb8d763f8d9e2ed62cfcc03fb9c", "shasum": "" }, "require": { @@ -1384,20 +1385,20 @@ "issues": "https://github.com/laravel/ai/issues", "source": "https://github.com/laravel/ai" }, - "time": "2026-03-12T19:36:02+00:00" + "time": "2026-03-18T14:44:36+00:00" }, { "name": "laravel/framework", - "version": "v12.54.1", + "version": "v12.56.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "325497463e7599cd14224c422c6e5dd2fe832868" + "reference": "dac16d424b59debb2273910dde88eb7050a2a709" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/325497463e7599cd14224c422c6e5dd2fe832868", - "reference": "325497463e7599cd14224c422c6e5dd2fe832868", + "url": "https://api.github.com/repos/laravel/framework/zipball/dac16d424b59debb2273910dde88eb7050a2a709", + "reference": "dac16d424b59debb2273910dde88eb7050a2a709", "shasum": "" }, "require": { @@ -1513,7 +1514,7 @@ "orchestra/testbench-core": "^10.9.0", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", - "phpstan/phpstan": "^2.0", + "phpstan/phpstan": "^2.1.41", "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", "predis/predis": "^2.3|^3.0", "resend/resend-php": "^0.10.0|^1.0", @@ -1606,7 +1607,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-10T20:25:56+00:00" + "time": "2026-03-26T14:51:54+00:00" }, { "name": "laravel/prompts", @@ -1669,32 +1670,31 @@ }, { "name": "laravel/sanctum", - "version": "v4.2.0", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677" + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677", - "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", "shasum": "" }, "require": { "ext-json": "*", - "illuminate/console": "^11.0|^12.0", - "illuminate/contracts": "^11.0|^12.0", - "illuminate/database": "^11.0|^12.0", - "illuminate/support": "^11.0|^12.0", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", "php": "^8.2", - "symfony/console": "^7.0" + "symfony/console": "^7.0|^8.0" }, "require-dev": { "mockery/mockery": "^1.6", - "orchestra/testbench": "^9.0|^10.0", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^11.3" + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" }, "type": "library", "extra": { @@ -1729,20 +1729,20 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2025-07-09T19:45:24+00:00" + "time": "2026-02-07T17:19:31+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.10", + "version": "v2.0.11", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" + "reference": "d1af40ac4a6ccc12bd062a7184f63c9995a63bdd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/d1af40ac4a6ccc12bd062a7184f63c9995a63bdd", + "reference": "d1af40ac4a6ccc12bd062a7184f63c9995a63bdd", "shasum": "" }, "require": { @@ -1790,20 +1790,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-02-20T19:59:49+00:00" + "time": "2026-04-07T13:32:18+00:00" }, { "name": "laravel/tinker", - "version": "v2.10.1", + "version": "v2.11.1", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3" + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/22177cc71807d38f2810c6204d8f7183d88a57d3", - "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", "shasum": "" }, "require": { @@ -1812,7 +1812,7 @@ "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", "php": "^7.2.5|^8.0", "psy/psysh": "^0.11.1|^0.12.0", - "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0" + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", @@ -1854,22 +1854,22 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.10.1" + "source": "https://github.com/laravel/tinker/tree/v2.11.1" }, - "time": "2025-01-27T14:24:01+00:00" + "time": "2026-02-06T14:12:35+00:00" }, { "name": "league/commonmark", - "version": "2.8.1", + "version": "2.8.2", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "84b1ca48347efdbe775426f108622a42735a6579" + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/84b1ca48347efdbe775426f108622a42735a6579", - "reference": "84b1ca48347efdbe775426f108622a42735a6579", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", "shasum": "" }, "require": { @@ -1963,7 +1963,7 @@ "type": "tidelift" } ], - "time": "2026-03-05T21:37:03+00:00" + "time": "2026-03-19T13:16:38+00:00" }, { "name": "league/config", @@ -2049,16 +2049,16 @@ }, { "name": "league/flysystem", - "version": "3.32.0", + "version": "3.33.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725" + "reference": "570b8871e0ce693764434b29154c54b434905350" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", + "reference": "570b8871e0ce693764434b29154c54b434905350", "shasum": "" }, "require": { @@ -2126,9 +2126,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.32.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" }, - "time": "2026-02-25T17:01:41+00:00" + "time": "2026-03-25T07:59:30+00:00" }, { "name": "league/flysystem-local", @@ -2237,20 +2237,20 @@ }, { "name": "league/uri", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76" + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.8", + "league/uri-interfaces": "^7.8.1", "php": "^8.1", "psr/http-factory": "^1" }, @@ -2323,7 +2323,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.8.0" + "source": "https://github.com/thephpleague/uri/tree/7.8.1" }, "funding": [ { @@ -2331,20 +2331,20 @@ "type": "github" } ], - "time": "2026-01-14T17:24:56+00:00" + "time": "2026-03-15T20:22:25+00:00" }, { "name": "league/uri-interfaces", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", "shasum": "" }, "require": { @@ -2407,7 +2407,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" }, "funding": [ { @@ -2415,33 +2415,32 @@ "type": "github" } ], - "time": "2026-01-15T06:54:53+00:00" + "time": "2026-03-08T20:05:35+00:00" }, { "name": "mobiledetect/mobiledetectlib", - "version": "4.8.09", + "version": "4.8.10", "source": { "type": "git", "url": "https://github.com/serbanghita/Mobile-Detect.git", - "reference": "a06fe2e546a06bb8c2639d6823d5250b2efb3209" + "reference": "96b1e1fa9a968de7660a031106ab529f659d0192" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/a06fe2e546a06bb8c2639d6823d5250b2efb3209", - "reference": "a06fe2e546a06bb8c2639d6823d5250b2efb3209", + "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/96b1e1fa9a968de7660a031106ab529f659d0192", + "reference": "96b1e1fa9a968de7660a031106ab529f659d0192", "shasum": "" }, "require": { "php": ">=8.0", - "psr/cache": "^3.0", "psr/simple-cache": "^3" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^v3.65.0", + "friendsofphp/php-cs-fixer": "^v3.75.0", "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.12.x-dev", - "phpunit/phpunit": "^9.6.18", - "squizlabs/php_codesniffer": "^3.11.1" + "phpstan/phpstan": "^2.1.11", + "phpunit/phpunit": "^9.6.22", + "squizlabs/php_codesniffer": "^3.12.1" }, "type": "library", "autoload": { @@ -2472,7 +2471,7 @@ ], "support": { "issues": "https://github.com/serbanghita/Mobile-Detect/issues", - "source": "https://github.com/serbanghita/Mobile-Detect/tree/4.8.09" + "source": "https://github.com/serbanghita/Mobile-Detect/tree/4.8.10" }, "funding": [ { @@ -2480,7 +2479,7 @@ "type": "github" } ], - "time": "2024-12-10T15:32:06+00:00" + "time": "2026-01-09T16:21:59+00:00" }, { "name": "monolog/monolog", @@ -2587,16 +2586,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.2", + "version": "3.11.4", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "57d696f4ec76d8560cc13b9d16ec01afc4379d04" + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/57d696f4ec76d8560cc13b9d16ec01afc4379d04", - "reference": "57d696f4ec76d8560cc13b9d16ec01afc4379d04", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", "shasum": "" }, "require": { @@ -2688,7 +2687,7 @@ "type": "tidelift" } ], - "time": "2026-03-10T21:43:48+00:00" + "time": "2026-04-07T09:57:54+00:00" }, { "name": "nette/schema", @@ -2850,16 +2849,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.6.1", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -2902,9 +2901,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-08-13T20:13:15+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "nunomaduro/termwind", @@ -3196,16 +3195,16 @@ }, { "name": "prism-php/prism", - "version": "v0.99.21", + "version": "v0.99.22", "source": { "type": "git", "url": "https://github.com/prism-php/prism.git", - "reference": "95272567629a62831294f63b1b927b1e2e608daf" + "reference": "989f67567aef69c613eae6e932d615fb96e2f5d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/prism-php/prism/zipball/95272567629a62831294f63b1b927b1e2e608daf", - "reference": "95272567629a62831294f63b1b927b1e2e608daf", + "url": "https://api.github.com/repos/prism-php/prism/zipball/989f67567aef69c613eae6e932d615fb96e2f5d7", + "reference": "989f67567aef69c613eae6e932d615fb96e2f5d7", "shasum": "" }, "require": { @@ -3263,7 +3262,7 @@ "description": "A powerful Laravel package for integrating Large Language Models (LLMs) into your applications.", "support": { "issues": "https://github.com/prism-php/prism/issues", - "source": "https://github.com/prism-php/prism/tree/v0.99.21" + "source": "https://github.com/prism-php/prism/tree/v0.99.22" }, "funding": [ { @@ -3271,56 +3270,7 @@ "type": "github" } ], - "time": "2026-03-01T21:12:44+00:00" - }, - { - "name": "psr/cache", - "version": "3.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for caching libraries", - "keywords": [ - "cache", - "psr", - "psr-6" - ], - "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" - }, - "time": "2021-02-03T23:26:27+00:00" + "time": "2026-03-12T17:55:23+00:00" }, { "name": "psr/clock", @@ -3736,16 +3686,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.12", + "version": "v0.12.22", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "cd23863404a40ccfaf733e3af4db2b459837f7e7" + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/cd23863404a40ccfaf733e3af4db2b459837f7e7", - "reference": "cd23863404a40ccfaf733e3af4db2b459837f7e7", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/3be75d5b9244936dd4ac62ade2bfb004d13acf0f", + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f", "shasum": "" }, "require": { @@ -3753,18 +3703,19 @@ "ext-tokenizer": "*", "nikic/php-parser": "^5.0 || ^4.0", "php": "^8.0 || ^7.4", - "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", - "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" }, "conflict": { "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.2" + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" }, "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", - "ext-pdo-sqlite": "The doc command requires SQLite to work.", "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." }, "bin": [ @@ -3808,9 +3759,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.12" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.22" }, - "time": "2025-09-20T13:46:31+00:00" + "time": "2026-03-22T23:03:24+00:00" }, { "name": "ralouphie/getallheaders", @@ -4012,27 +3963,27 @@ }, { "name": "rcrowe/twigbridge", - "version": "v0.14.6", + "version": "v0.14.7", "source": { "type": "git", "url": "https://github.com/rcrowe/TwigBridge.git", - "reference": "0798ee4b5e5b943d0200850acaa87ccd82e2fe45" + "reference": "03a767c8d5c1d74d5f14e9fc754619a271822663" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rcrowe/TwigBridge/zipball/0798ee4b5e5b943d0200850acaa87ccd82e2fe45", - "reference": "0798ee4b5e5b943d0200850acaa87ccd82e2fe45", + "url": "https://api.github.com/repos/rcrowe/TwigBridge/zipball/03a767c8d5c1d74d5f14e9fc754619a271822663", + "reference": "03a767c8d5c1d74d5f14e9fc754619a271822663", "shasum": "" }, "require": { - "illuminate/support": "^9|^10|^11|^12", - "illuminate/view": "^9|^10|^11|^12", + "illuminate/support": "^9|^10|^11|^12|^13", + "illuminate/view": "^9|^10|^11|^12|^13", "php": "^8.1", "twig/twig": "~3.21" }, "require-dev": { "ext-json": "*", - "laravel/framework": "^9|^10|^11|^12", + "laravel/framework": "^9|^10|^11|^12|^13", "mockery/mockery": "^1.3.1", "phpunit/phpunit": "^8.5.8 || ^9.3.7 || ^10.0 || ^11.0 || ^12.0", "squizlabs/php_codesniffer": "^3.6" @@ -4078,22 +4029,22 @@ ], "support": { "issues": "https://github.com/rcrowe/TwigBridge/issues", - "source": "https://github.com/rcrowe/TwigBridge/tree/v0.14.6" + "source": "https://github.com/rcrowe/TwigBridge/tree/v0.14.7" }, - "time": "2025-08-20T11:25:49+00:00" + "time": "2026-03-20T16:59:18+00:00" }, { "name": "sentry/sentry", - "version": "4.16.0", + "version": "4.24.0", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-php.git", - "reference": "c5b086e4235762da175034bc463b0d31cbb38d2e" + "reference": "5055cc792f630a427fdf9dcccd88faa94b50fd30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/c5b086e4235762da175034bc463b0d31cbb38d2e", - "reference": "c5b086e4235762da175034bc463b0d31cbb38d2e", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/5055cc792f630a427fdf9dcccd88faa94b50fd30", + "reference": "5055cc792f630a427fdf9dcccd88faa94b50fd30", "shasum": "" }, "require": { @@ -4104,7 +4055,7 @@ "jean85/pretty-package-versions": "^1.5|^2.0.4", "php": "^7.2|^8.0", "psr/log": "^1.0|^2.0|^3.0", - "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0" + "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0" }, "conflict": { "raven/raven": "*" @@ -4114,11 +4065,15 @@ "guzzlehttp/promises": "^2.0.3", "guzzlehttp/psr7": "^1.8.4|^2.1.1", "monolog/monolog": "^1.6|^2.0|^3.0", + "nyholm/psr7": "^1.8", + "open-telemetry/api": "^1.0", + "open-telemetry/exporter-otlp": "^1.0", + "open-telemetry/sdk": "^1.0", "phpbench/phpbench": "^1.0", "phpstan/phpstan": "^1.3", - "phpunit/phpunit": "^8.5|^9.6", - "symfony/phpunit-bridge": "^5.2|^6.0|^7.0", - "vimeo/psalm": "^4.17" + "phpunit/phpunit": "^8.5.52|^9.6.34", + "spiral/roadrunner-http": "^3.6", + "spiral/roadrunner-worker": "^3.6" }, "suggest": { "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." @@ -4157,7 +4112,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-php/issues", - "source": "https://github.com/getsentry/sentry-php/tree/4.16.0" + "source": "https://github.com/getsentry/sentry-php/tree/4.24.0" }, "funding": [ { @@ -4169,7 +4124,7 @@ "type": "custom" } ], - "time": "2025-09-22T13:38:03+00:00" + "time": "2026-04-01T14:34:34+00:00" }, { "name": "sentry/sentry-laravel", @@ -4357,22 +4312,21 @@ }, { "name": "symfony/clock", - "version": "v7.4.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "9169f24776edde469914c1e7a1442a50f7a4e110" + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110", - "reference": "9169f24776edde469914c1e7a1442a50f7a4e110", + "url": "https://api.github.com/repos/symfony/clock/zipball/b55a638b189a6faa875e0ccdb00908fb87af95b3", + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3", "shasum": "" }, "require": { - "php": ">=8.2", - "psr/clock": "^1.0", - "symfony/polyfill-php83": "^1.28" + "php": ">=8.4", + "psr/clock": "^1.0" }, "provide": { "psr/clock-implementation": "1.0" @@ -4411,7 +4365,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.4.0" + "source": "https://github.com/symfony/clock/tree/v8.0.8" }, "funding": [ { @@ -4431,7 +4385,7 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:39:26+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/console", @@ -4533,20 +4487,20 @@ }, { "name": "symfony/css-selector", - "version": "v7.4.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "2e7c52c647b406e2107dd867db424a4dbac91864" + "reference": "8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/2e7c52c647b406e2107dd867db424a4dbac91864", - "reference": "2e7c52c647b406e2107dd867db424a4dbac91864", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed", + "reference": "8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "type": "library", "autoload": { @@ -4578,7 +4532,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.4.6" + "source": "https://github.com/symfony/css-selector/tree/v8.0.8" }, "funding": [ { @@ -4598,7 +4552,7 @@ "type": "tidelift" } ], - "time": "2026-02-17T07:53:42+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4669,16 +4623,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", "shasum": "" }, "require": { @@ -4727,7 +4681,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + "source": "https://github.com/symfony/error-handler/tree/v7.4.8" }, "funding": [ { @@ -4747,28 +4701,28 @@ "type": "tidelift" } ], - "time": "2026-01-20T16:42:42+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.4.4", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "dc2c0eba1af673e736bb851d747d266108aea746" + "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746", - "reference": "dc2c0eba1af673e736bb851d747d266108aea746", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f662acc6ab22a3d6d716dcb44c381c6002940df6", + "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<6.4", + "symfony/security-http": "<7.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -4777,14 +4731,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4812,7 +4766,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.8" }, "funding": [ { @@ -4832,7 +4786,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:34+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4912,16 +4866,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" + "reference": "e0be088d22278583a82da281886e8c3592fbf149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", "shasum": "" }, "require": { @@ -4956,7 +4910,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.6" + "source": "https://github.com/symfony/finder/tree/v7.4.8" }, "funding": [ { @@ -4976,20 +4930,20 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:40:50+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81" + "reference": "9381209597ec66c25be154cbf2289076e64d1eab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81", - "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab", + "reference": "9381209597ec66c25be154cbf2289076e64d1eab", "shasum": "" }, "require": { @@ -5038,7 +4992,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.7" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.8" }, "funding": [ { @@ -5058,20 +5012,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:15:18+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1" + "reference": "017e76ad089bac281553389269e259e155935e1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1", - "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/017e76ad089bac281553389269e259e155935e1a", + "reference": "017e76ad089bac281553389269e259e155935e1a", "shasum": "" }, "require": { @@ -5157,7 +5111,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.7" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.8" }, "funding": [ { @@ -5177,20 +5131,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T16:33:18+00:00" + "time": "2026-03-31T20:57:01+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" + "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "url": "https://api.github.com/repos/symfony/mailer/zipball/f6ea532250b476bfc1b56699b388a1bdbf168f62", + "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62", "shasum": "" }, "require": { @@ -5241,7 +5195,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.6" + "source": "https://github.com/symfony/mailer/tree/v7.4.8" }, "funding": [ { @@ -5261,20 +5215,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/mime", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1" + "reference": "6df02f99998081032da3407a8d6c4e1dcb5d4379" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1", + "url": "https://api.github.com/repos/symfony/mime/zipball/6df02f99998081032da3407a8d6c4e1dcb5d4379", + "reference": "6df02f99998081032da3407a8d6c4e1dcb5d4379", "shasum": "" }, "require": { @@ -5330,7 +5284,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.7" + "source": "https://github.com/symfony/mime/tree/v7.4.8" }, "funding": [ { @@ -5350,24 +5304,24 @@ "type": "tidelift" } ], - "time": "2026-03-05T15:24:09+00:00" + "time": "2026-03-30T14:11:46+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.3.3", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d" + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/0ff2f5c3df08a395232bbc3c2eb7e84912df911d", - "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8", + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", @@ -5401,7 +5355,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.3.3" + "source": "https://github.com/symfony/options-resolver/tree/v8.0.8" }, "funding": [ { @@ -5421,7 +5375,7 @@ "type": "tidelift" } ], - "time": "2025-08-05T10:16:07+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-ctype", @@ -6254,16 +6208,16 @@ }, { "name": "symfony/process", - "version": "v7.4.5", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "608476f4604102976d687c483ac63a79ba18cc97" + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", - "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a", + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a", "shasum": "" }, "require": { @@ -6295,7 +6249,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.5" + "source": "https://github.com/symfony/process/tree/v7.4.8" }, "funding": [ { @@ -6315,26 +6269,26 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/psr-http-message-bridge", - "version": "v7.3.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/psr-http-message-bridge.git", - "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f" + "reference": "76f1a57719a4a04c0ea18678a6c9305b5dcb9da8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/03f2f72319e7acaf2a9f6fcbe30ef17eec51594f", - "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/76f1a57719a4a04c0ea18678a6c9305b5dcb9da8", + "reference": "76f1a57719a4a04c0ea18678a6c9305b5dcb9da8", "shasum": "" }, "require": { "php": ">=8.2", "psr/http-message": "^1.0|^2.0", - "symfony/http-foundation": "^6.4|^7.0" + "symfony/http-foundation": "^6.4|^7.0|^8.0" }, "conflict": { "php-http/discovery": "<1.15", @@ -6344,11 +6298,12 @@ "nyholm/psr7": "^1.1", "php-http/discovery": "^1.15", "psr/log": "^1.1.4|^2|^3", - "symfony/browser-kit": "^6.4|^7.0", - "symfony/config": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/framework-bundle": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0" + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", + "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0" }, "type": "symfony-bridge", "autoload": { @@ -6382,7 +6337,7 @@ "psr-7" ], "support": { - "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.3.0" + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.4.8" }, "funding": [ { @@ -6393,25 +6348,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-26T08:57:56+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/routing", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" + "reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", + "url": "https://api.github.com/repos/symfony/routing/zipball/9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b", + "reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b", "shasum": "" }, "require": { @@ -6463,7 +6422,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.6" + "source": "https://github.com/symfony/routing/tree/v7.4.8" }, "funding": [ { @@ -6483,7 +6442,7 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/service-contracts", @@ -6664,34 +6623,27 @@ }, { "name": "symfony/translation", - "version": "v7.4.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "1888cf064399868af3784b9e043240f1d89d25ce" + "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/1888cf064399868af3784b9e043240f1d89d25ce", - "reference": "1888cf064399868af3784b9e043240f1d89d25ce", + "url": "https://api.github.com/repos/symfony/translation/zipball/27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", + "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", - "symfony/translation-contracts": "^2.5.3|^3.3" + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" }, "conflict": { "nikic/php-parser": "<5.0", - "symfony/config": "<6.4", - "symfony/console": "<6.4", - "symfony/dependency-injection": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<6.4", - "symfony/service-contracts": "<2.5", - "symfony/twig-bundle": "<6.4", - "symfony/yaml": "<6.4" + "symfony/service-contracts": "<2.5" }, "provide": { "symfony/translation-implementation": "2.3|3.0" @@ -6699,17 +6651,17 @@ "require-dev": { "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/routing": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -6740,7 +6692,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.4.6" + "source": "https://github.com/symfony/translation/tree/v8.0.8" }, "funding": [ { @@ -6760,7 +6712,7 @@ "type": "tidelift" } ], - "time": "2026-02-17T07:53:42+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/translation-contracts", @@ -6846,16 +6798,16 @@ }, { "name": "symfony/uid", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "url": "https://api.github.com/repos/symfony/uid/zipball/6883ebdf7bf6a12b37519dbc0df62b0222401b56", + "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56", "shasum": "" }, "require": { @@ -6900,7 +6852,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.4" + "source": "https://github.com/symfony/uid/tree/v7.4.8" }, "funding": [ { @@ -6920,20 +6872,20 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:30:35+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", "shasum": "" }, "require": { @@ -6987,7 +6939,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" }, "funding": [ { @@ -7007,7 +6959,7 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:20+00:00" + "time": "2026-03-30T13:44:50+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -7066,16 +7018,16 @@ }, { "name": "twig/twig", - "version": "v3.21.1", + "version": "v3.24.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d" + "reference": "a6769aefb305efef849dc25c9fd1653358c148f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/285123877d4dd97dd7c11842ac5fb7e86e60d81d", - "reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0", + "reference": "a6769aefb305efef849dc25c9fd1653358c148f0", "shasum": "" }, "require": { @@ -7085,7 +7037,8 @@ "symfony/polyfill-mbstring": "^1.3" }, "require-dev": { - "phpstan/phpstan": "^2.0", + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", "psr/container": "^1.0|^2.0", "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" }, @@ -7129,7 +7082,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.21.1" + "source": "https://github.com/twigphp/Twig/tree/v3.24.0" }, "funding": [ { @@ -7141,7 +7094,7 @@ "type": "tidelift" } ], - "time": "2025-05-03T07:21:55+00:00" + "time": "2026-03-17T21:31:11+00:00" }, { "name": "vlucas/phpdotenv", @@ -7305,16 +7258,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.8.4", + "version": "v7.8.5", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "130a9bf0e269ee5f5b320108f794ad03e275cad4" + "reference": "9b324c8fc319cf9728b581c7a90e1c8f6361c5e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/130a9bf0e269ee5f5b320108f794ad03e275cad4", - "reference": "130a9bf0e269ee5f5b320108f794ad03e275cad4", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/9b324c8fc319cf9728b581c7a90e1c8f6361c5e5", + "reference": "9b324c8fc319cf9728b581c7a90e1c8f6361c5e5", "shasum": "" }, "require": { @@ -7322,27 +7275,27 @@ "ext-pcre": "*", "ext-reflection": "*", "ext-simplexml": "*", - "fidry/cpu-core-counter": "^1.2.0", + "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", - "php": "~8.2.0 || ~8.3.0 || ~8.4.0", - "phpunit/php-code-coverage": "^11.0.10", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "phpunit/php-code-coverage": "^11.0.12", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-timer": "^7.0.1", - "phpunit/phpunit": "^11.5.24", + "phpunit/phpunit": "^11.5.46", "sebastian/environment": "^7.2.1", - "symfony/console": "^6.4.22 || ^7.3.0", - "symfony/process": "^6.4.20 || ^7.3.0" + "symfony/console": "^6.4.22 || ^7.3.4 || ^8.0.3", + "symfony/process": "^6.4.20 || ^7.3.4 || ^8.0.3" }, "require-dev": { "doctrine/coding-standard": "^12.0.0", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.17", + "phpstan/phpstan": "^2.1.33", "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.6", - "phpstan/phpstan-strict-rules": "^2.0.4", - "squizlabs/php_codesniffer": "^3.13.2", - "symfony/filesystem": "^6.4.13 || ^7.3.0" + "phpstan/phpstan-phpunit": "^2.0.11", + "phpstan/phpstan-strict-rules": "^2.0.7", + "squizlabs/php_codesniffer": "^3.13.5", + "symfony/filesystem": "^6.4.13 || ^7.3.2 || ^8.0.1" }, "bin": [ "bin/paratest", @@ -7382,7 +7335,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.8.4" + "source": "https://github.com/paratestphp/paratest/tree/v7.8.5" }, "funding": [ { @@ -7394,7 +7347,7 @@ "type": "paypal" } ], - "time": "2025-06-23T06:07:21+00:00" + "time": "2026-01-08T08:02:38+00:00" }, { "name": "doctrine/deprecations", @@ -7960,29 +7913,29 @@ }, { "name": "laravel/sail", - "version": "v1.46.0", + "version": "v1.56.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "eb90c4f113c4a9637b8fdd16e24cfc64f2b0ae6e" + "reference": "f43426bb42a1cb7a51a3861d9138063e54766d28" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/eb90c4f113c4a9637b8fdd16e24cfc64f2b0ae6e", - "reference": "eb90c4f113c4a9637b8fdd16e24cfc64f2b0ae6e", + "url": "https://api.github.com/repos/laravel/sail/zipball/f43426bb42a1cb7a51a3861d9138063e54766d28", + "reference": "f43426bb42a1cb7a51a3861d9138063e54766d28", "shasum": "" }, "require": { - "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0", - "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0", - "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0", + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", "php": "^8.0", - "symfony/console": "^6.0|^7.0", - "symfony/yaml": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" }, "require-dev": { - "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", - "phpstan/phpstan": "^1.10" + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0" }, "bin": [ "bin/sail" @@ -8019,7 +7972,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2025-09-23T13:44:39+00:00" + "time": "2026-04-01T15:17:32+00:00" }, { "name": "mockery/mockery", @@ -8166,39 +8119,36 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.2", + "version": "v8.9.3", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb" + "reference": "b0d8ab95b29c3189aeeb902d81215231df4c1b64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", - "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/b0d8ab95b29c3189aeeb902d81215231df4c1b64", + "reference": "b0d8ab95b29c3189aeeb902d81215231df4c1b64", "shasum": "" }, "require": { - "filp/whoops": "^2.18.1", - "nunomaduro/termwind": "^2.3.1", + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.3.0" + "symfony/console": "^7.4.8 || ^8.0.4" }, "conflict": { - "laravel/framework": "<11.44.2 || >=13.0.0", - "phpunit/phpunit": "<11.5.15 || >=13.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.8.3", - "larastan/larastan": "^3.4.2", - "laravel/framework": "^11.44.2 || ^12.18", - "laravel/pint": "^1.22.1", - "laravel/sail": "^1.43.1", - "laravel/sanctum": "^4.1.1", - "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2", - "sebastian/environment": "^7.2.1 || ^8.0" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.3", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.2.0", + "laravel/pint": "^1.29.0", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.0.0", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.0.0" }, "type": "library", "extra": { @@ -8261,42 +8211,42 @@ "type": "patreon" } ], - "time": "2025-06-25T02:12:12+00:00" + "time": "2026-04-06T19:25:53+00:00" }, { "name": "pestphp/pest", - "version": "v3.8.4", + "version": "v3.8.6", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "72cf695554420e21858cda831d5db193db102574" + "reference": "8871a6f5ef1de8e7c8dee2a270991449a7b6af73" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/72cf695554420e21858cda831d5db193db102574", - "reference": "72cf695554420e21858cda831d5db193db102574", + "url": "https://api.github.com/repos/pestphp/pest/zipball/8871a6f5ef1de8e7c8dee2a270991449a7b6af73", + "reference": "8871a6f5ef1de8e7c8dee2a270991449a7b6af73", "shasum": "" }, "require": { - "brianium/paratest": "^7.8.4", - "nunomaduro/collision": "^8.8.2", - "nunomaduro/termwind": "^2.3.1", + "brianium/paratest": "^7.8.5", + "nunomaduro/collision": "^8.9.1", + "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^3.0.0", "pestphp/pest-plugin-arch": "^3.1.1", "pestphp/pest-plugin-mutate": "^3.0.5", "php": "^8.2.0", - "phpunit/phpunit": "^11.5.33" + "phpunit/phpunit": "^11.5.50" }, "conflict": { "filp/whoops": "<2.16.0", - "phpunit/phpunit": ">11.5.33", + "phpunit/phpunit": ">11.5.50", "sebastian/exporter": "<6.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { "pestphp/pest-dev-tools": "^3.4.0", "pestphp/pest-plugin-type-coverage": "^3.6.1", - "symfony/process": "^7.3.0" + "symfony/process": "^7.4.5" }, "bin": [ "bin/pest" @@ -8361,7 +8311,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v3.8.4" + "source": "https://github.com/pestphp/pest/tree/v3.8.6" }, "funding": [ { @@ -8373,7 +8323,7 @@ "type": "github" } ], - "time": "2025-08-20T19:12:42+00:00" + "time": "2026-03-10T21:04:33+00:00" }, { "name": "pestphp/pest-plugin", @@ -8834,16 +8784,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.3", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9" + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/94f8051919d1b0369a6bcc7931d679a511c03fe9", - "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", "shasum": "" }, "require": { @@ -8851,9 +8801,9 @@ "ext-filter": "*", "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1" + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { "mockery/mockery": "~1.3.5 || ~1.6.0", @@ -8862,7 +8812,8 @@ "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" }, "type": "library", "extra": { @@ -8892,44 +8843,44 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.3" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" }, - "time": "2025-08-01T19:43:32+00:00" + "time": "2026-03-18T20:49:53+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.10.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a" + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", + "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" + "phpstan/phpdoc-parser": "^2.0" }, "require-dev": { "ext-tokenizer": "*", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" + "psalm/phar": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -8950,22 +8901,22 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" }, - "time": "2024-11-09T15:12:26+00:00" + "time": "2026-01-06T21:53:42+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.0", + "version": "2.3.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", "shasum": "" }, "require": { @@ -8997,41 +8948,41 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" }, - "time": "2025-08-30T15:50:23+00:00" + "time": "2026-01-25T14:56:51+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "11.0.11", + "version": "11.0.12", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4" + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", - "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.4.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.2", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-text-template": "^4.0.1", "sebastian/code-unit-reverse-lookup": "^4.0.1", "sebastian/complexity": "^4.0.1", - "sebastian/environment": "^7.2.0", + "sebastian/environment": "^7.2.1", "sebastian/lines-of-code": "^3.0.1", "sebastian/version": "^5.0.2", - "theseer/tokenizer": "^1.2.3" + "theseer/tokenizer": "^1.3.1" }, "require-dev": { - "phpunit/phpunit": "^11.5.2" + "phpunit/phpunit": "^11.5.46" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -9069,7 +9020,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.11" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" }, "funding": [ { @@ -9089,32 +9040,32 @@ "type": "tidelift" } ], - "time": "2025-08-27T14:37:49+00:00" + "time": "2025-12-24T07:01:01+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "5.1.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -9142,15 +9093,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2024-08-27T05:02:59+00:00" + "time": "2026-02-02T13:52:54+00:00" }, { "name": "phpunit/php-invoker", @@ -9338,16 +9301,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.33", + "version": "11.5.50", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "5965e9ff57546cb9137c0ff6aa78cb7442b05cf6" + "reference": "fdfc727f0fcacfeb8fcb30c7e5da173125b58be3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5965e9ff57546cb9137c0ff6aa78cb7442b05cf6", - "reference": "5965e9ff57546cb9137c0ff6aa78cb7442b05cf6", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fdfc727f0fcacfeb8fcb30c7e5da173125b58be3", + "reference": "fdfc727f0fcacfeb8fcb30c7e5da173125b58be3", "shasum": "" }, "require": { @@ -9361,17 +9324,17 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.10", + "phpunit/php-code-coverage": "^11.0.12", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", "phpunit/php-timer": "^7.0.1", "sebastian/cli-parser": "^3.0.2", "sebastian/code-unit": "^3.0.3", - "sebastian/comparator": "^6.3.2", + "sebastian/comparator": "^6.3.3", "sebastian/diff": "^6.0.2", "sebastian/environment": "^7.2.1", - "sebastian/exporter": "^6.3.0", + "sebastian/exporter": "^6.3.2", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", "sebastian/type": "^5.1.3", @@ -9419,7 +9382,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.33" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.50" }, "funding": [ { @@ -9443,7 +9406,7 @@ "type": "tidelift" } ], - "time": "2025-08-16T05:19:02+00:00" + "time": "2026-01-27T05:59:18+00:00" }, { "name": "sebastian/cli-parser", @@ -9617,16 +9580,16 @@ }, { "name": "sebastian/comparator", - "version": "6.3.2", + "version": "6.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", "shasum": "" }, "require": { @@ -9685,7 +9648,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" }, "funding": [ { @@ -9705,7 +9668,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T08:07:46+00:00" + "time": "2026-01-24T09:26:40+00:00" }, { "name": "sebastian/complexity", @@ -10485,28 +10448,27 @@ }, { "name": "symfony/yaml", - "version": "v7.4.8", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883" + "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/c58fdf7b3d6c2995368264c49e4e8b05bcff2883", - "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883", + "url": "https://api.github.com/repos/symfony/yaml/zipball/54174ab48c0c0f9e21512b304be17f8150ccf8f1", + "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0" }, "bin": [ "Resources/bin/yaml-lint" @@ -10537,7 +10499,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.8" + "source": "https://github.com/symfony/yaml/tree/v8.0.8" }, "funding": [ { @@ -10557,28 +10519,28 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", - "version": "0.8.5", + "version": "0.8.7", "source": { "type": "git", "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", - "reference": "cf6fb197b676ba716837c886baca842e4db29005" + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/cf6fb197b676ba716837c886baca842e4db29005", - "reference": "cf6fb197b676ba716837c886baca842e4db29005", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", "shasum": "" }, "require": { "nikic/php-parser": "^4.18.0 || ^5.0.0", "php": "^8.1.0", - "phpdocumentor/reflection-docblock": "^5.3.0", - "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", - "symfony/finder": "^6.4.0 || ^7.0.0" + "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "require-dev": { "laravel/pint": "^1.13.7", @@ -10614,22 +10576,22 @@ ], "support": { "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", - "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.5" + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" }, - "time": "2025-04-20T20:23:40+00:00" + "time": "2026-02-17T17:25:14+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -10658,7 +10620,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -10666,27 +10628,27 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" }, { "name": "webmozart/assert", - "version": "1.12.1", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + "reference": "1b99650e7ffcad232624a260bc7fbdec2ffc407c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/1b99650e7ffcad232624a260bc7fbdec2ffc407c", + "reference": "1b99650e7ffcad232624a260bc7fbdec2ffc407c", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.2" }, "suggest": { "ext-intl": "", @@ -10696,7 +10658,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.10-dev" + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -10712,6 +10674,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -10722,9 +10688,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" + "source": "https://github.com/webmozarts/assert/tree/2.2.0" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2026-04-09T16:54:47+00:00" } ], "aliases": [], @@ -10733,7 +10699,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.3" + "php": "^8.4" }, "platform-dev": {}, "plugin-api-version": "2.9.0" From 79486f1537a23723ce6d333377d890d5fee08137 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Fri, 10 Apr 2026 15:09:02 +0300 Subject: [PATCH 13/21] Fix var type --- _api_app/app/Sites/Sections/SectionsMenuRenderService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_api_app/app/Sites/Sections/SectionsMenuRenderService.php b/_api_app/app/Sites/Sections/SectionsMenuRenderService.php index bba7af655..04cf0d826 100644 --- a/_api_app/app/Sites/Sections/SectionsMenuRenderService.php +++ b/_api_app/app/Sites/Sections/SectionsMenuRenderService.php @@ -176,7 +176,7 @@ private function getViewData( ]; } - private function getUrl($section, $site, $sections, $siteSettings, $isEditMode, $isPreviewMode, ?string $tag = null) + private function getUrl($section, $site, $sections, $siteSettings, $isEditMode, $isPreviewMode, ?array $tag = null) { $urlParts = []; $isExternalLink = isset($section['@attributes']['type']) && $section['@attributes']['type'] == 'external_link'; From 721ec2b6b0be085b040784e663d38429325c199f Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Mon, 13 Apr 2026 09:12:46 +0300 Subject: [PATCH 14/21] Preserve ai assistant state for each site using local storage --- .../app/ai-assistant/ai-assistant.state.ts | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/editor/src/app/ai-assistant/ai-assistant.state.ts b/editor/src/app/ai-assistant/ai-assistant.state.ts index fb1fb1cea..054db7795 100644 --- a/editor/src/app/ai-assistant/ai-assistant.state.ts +++ b/editor/src/app/ai-assistant/ai-assistant.state.ts @@ -5,6 +5,7 @@ import { EMPTY, from, concat } from 'rxjs'; import { Store } from '@ngxs/store'; import { AppState } from '../app-state/app.state'; +import { UpdateAppStateAction } from '../app-state/app.actions'; import { SiteSettingsState } from '../sites/settings/site-settings.state'; import { SiteSectionsState } from '../sites/sections/sections-state/site-sections.state'; import { UpdateSiteTemplateSettingsAction } from '../sites/template-settings/site-template-settings.actions'; @@ -29,6 +30,7 @@ import { AiMessageReceivedAction, ClearAiChatAction, } from './ai-assistant.actions'; +import { UserLoginAction } from '../user/user.actions'; export interface AiMessage { role: 'user' | 'assistant'; @@ -85,13 +87,17 @@ export interface AiAssistantStateModel { messages: AiMessage[]; isLoading: boolean; changeHistory: AiChangeHistoryItem[]; + loadedSite: string | null; } +const STORAGE_KEY_PREFIX = 'berta_ai_chat_'; + const defaults: AiAssistantStateModel = { isOpen: false, messages: [], isLoading: false, changeHistory: [], + loadedSite: null, }; @State({ @@ -120,6 +126,42 @@ export class AiAssistantState { private aiAssistantService: AiAssistantService, ) {} + private saveToStorage(site: string, messages: AiMessage[], changeHistory: AiChangeHistoryItem[]) { + try { + localStorage.setItem(STORAGE_KEY_PREFIX + site, JSON.stringify({ messages, changeHistory })); + } catch {} + } + + @Action(UpdateAppStateAction) + onAppStateUpdate( + { patchState, getState }: StateContext, + action: UpdateAppStateAction, + ) { + const site = action.payload?.site; + if (site == null) return; + if (getState().loadedSite === site) return; + + try { + const raw = localStorage.getItem(STORAGE_KEY_PREFIX + site); + if (raw) { + const { messages, changeHistory } = JSON.parse(raw); + patchState({ messages, changeHistory, loadedSite: site }); + } else { + patchState({ messages: [], changeHistory: [], loadedSite: site }); + } + } catch { + patchState({ messages: [], changeHistory: [], loadedSite: site }); + } + } + + @Action(UserLoginAction) + onLogin({ setState }: StateContext) { + Object.keys(localStorage) + .filter(key => key.startsWith(STORAGE_KEY_PREFIX)) + .forEach(key => localStorage.removeItem(key)); + setState(defaults); + } + @Action(ToggleAiAssistantAction) toggle({ patchState, getState }: StateContext) { patchState({ isOpen: !getState().isOpen }); @@ -284,12 +326,15 @@ export class AiAssistantState { changeHistory = hasChanges ? [...state.changeHistory, newItem] : state.changeHistory; } + const updatedMessages = [...state.messages, assistantMessage]; patchState({ - messages: [...state.messages, assistantMessage], + messages: updatedMessages, isLoading: false, changeHistory, }); + this.saveToStorage(site, updatedMessages, changeHistory); + for (const change of action.designChanges) { dispatch( new UpdateSiteTemplateSettingsAction(change.group, { @@ -415,6 +460,8 @@ export class AiAssistantState { }; }), }); + const updated = getState(); + this.saveToStorage(site, updated.messages, updated.changeHistory); } } }), @@ -453,6 +500,8 @@ export class AiAssistantState { }; }), }); + const updated = getState(); + this.saveToStorage(site, updated.messages, updated.changeHistory); } } }), @@ -568,6 +617,10 @@ export class AiAssistantState { }; }), }); + if (site) { + const updated = getState(); + this.saveToStorage(site, updated.messages, updated.changeHistory); + } } if (!change.description) return EMPTY; @@ -588,6 +641,10 @@ export class AiAssistantState { @Action(ClearAiChatAction) clearChat({ setState }: StateContext) { + const site = this.store.selectSnapshot(AppState.getSite); + if (site != null) { + localStorage.removeItem(STORAGE_KEY_PREFIX + site); + } setState(defaults); } } From d4eb670bde2bbbdd6f7eddabe31109b643b217c1 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Mon, 13 Apr 2026 10:19:17 +0300 Subject: [PATCH 15/21] Minimize option for ai assistant widget --- .../ai-assistant/ai-assistant.component.ts | 169 +++++++++++++----- .../app/ai-assistant/ai-assistant.state.ts | 4 +- 2 files changed, 125 insertions(+), 48 deletions(-) diff --git a/editor/src/app/ai-assistant/ai-assistant.component.ts b/editor/src/app/ai-assistant/ai-assistant.component.ts index 86a1405f8..73a960fe3 100644 --- a/editor/src/app/ai-assistant/ai-assistant.component.ts +++ b/editor/src/app/ai-assistant/ai-assistant.component.ts @@ -19,59 +19,94 @@ import { selector: 'berta-ai-assistant', template: ` @if (isOpen$ | async) { -
-
+
+
AI Assistant
- Clear - +
-
- @if ((messages$ | async)?.length === 0) { -

- Ask me to change design or site settings.
- e.g. "Make the background dark blue" or "Set the page title to My - Site" -

- } - @for (msg of messages$ | async; track $index) { -
+ @if ((messages$ | async)?.length === 0) { +

+ Ask me to change design or site settings.
+ e.g. "Make the background dark blue" or "Set the page title to + My Site" +

+ } + @for (msg of messages$ | async; track $index) { +
+ @if (msg.role === 'assistant') { + + } @else { + {{ msg.content }} + } +
+ } + @if (isLoading$ | async) { +
+ +
+ } +
+
+ +
- } - @if (isLoading$ | async) { -
- -
- } -
-
- - -
+ Send + +
+ }
} `, @@ -133,6 +168,37 @@ import { color: #333; } + .minimize-btn { + background: none; + border: none; + cursor: pointer; + line-height: 1; + padding: 0; + display: flex; + align-items: center; + } + + .minimize-btn:hover .drop-icon path { + stroke: #333; + } + + .drop-icon--flipped { + transform: scaleY(-1); + } + + .ai-panel--minimized { + bottom: auto; + } + + .ai-panel-header--clickable { + cursor: pointer; + user-select: none; + } + + .ai-panel-header--clickable:hover { + background: #f7f7f7; + } + .ai-messages { flex-grow: 1; overflow-y: auto; @@ -283,6 +349,7 @@ export class AiAssistantComponent implements AfterViewChecked, OnDestroy { messages$: Observable; isLoading$: Observable; inputText = ''; + isMinimized = false; @ViewChild('messagesContainer') private messagesContainer: ElementRef; @ViewChild('inputEl') private inputEl: ElementRef; @@ -350,6 +417,16 @@ export class AiAssistantComponent implements AfterViewChecked, OnDestroy { this.store.dispatch(new ToggleAiAssistantAction()); } + minimize() { + this.isMinimized = true; + } + + restore() { + this.isMinimized = false; + this.shouldFocus = true; + this.shouldScroll = true; + } + private scrollToBottom() { if (this.messagesContainer) { const el = this.messagesContainer.nativeElement; diff --git a/editor/src/app/ai-assistant/ai-assistant.state.ts b/editor/src/app/ai-assistant/ai-assistant.state.ts index 054db7795..4d51eb892 100644 --- a/editor/src/app/ai-assistant/ai-assistant.state.ts +++ b/editor/src/app/ai-assistant/ai-assistant.state.ts @@ -640,11 +640,11 @@ export class AiAssistantState { } @Action(ClearAiChatAction) - clearChat({ setState }: StateContext) { + clearChat({ patchState }: StateContext) { const site = this.store.selectSnapshot(AppState.getSite); if (site != null) { localStorage.removeItem(STORAGE_KEY_PREFIX + site); } - setState(defaults); + patchState({ messages: [], changeHistory: [], isLoading: false }); } } From 88ffc4742eafa478b2b231079f609a7a7dfc8c49 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Thu, 16 Apr 2026 12:32:42 +0300 Subject: [PATCH 16/21] Update AI chat placeholder text --- .mcp.json | 18 ++++++++++++++++++ _api_app/composer.lock | 10 +++++----- .../app/ai-assistant/ai-assistant.component.ts | 4 ++-- 3 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..b5416027e --- /dev/null +++ b/.mcp.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "bash", + "args": [ + "-c", + "cd _api_app && php artisan mcp:start laravel-boost" + ] + }, + "herd": { + "command": "bash", + "args": [ + "-c", + "SITE_PATH=\"$PWD/_api_app\" php /Applications/Herd.app/Contents/Resources/herd-mcp.phar" + ] + } + } +} diff --git a/_api_app/composer.lock b/_api_app/composer.lock index 5aefa9f62..b865078aa 100644 --- a/_api_app/composer.lock +++ b/_api_app/composer.lock @@ -7645,16 +7645,16 @@ }, { "name": "laravel/boost", - "version": "v2.4.2", + "version": "v2.4.3", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "74fedf18048d382e04eded004abe2ad1ee1d9a97" + "reference": "841d52905728cfac9f93c778a1758e740ce9a367" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/74fedf18048d382e04eded004abe2ad1ee1d9a97", - "reference": "74fedf18048d382e04eded004abe2ad1ee1d9a97", + "url": "https://api.github.com/repos/laravel/boost/zipball/841d52905728cfac9f93c778a1758e740ce9a367", + "reference": "841d52905728cfac9f93c778a1758e740ce9a367", "shasum": "" }, "require": { @@ -7707,7 +7707,7 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-04-07T14:34:49+00:00" + "time": "2026-04-10T15:59:10+00:00" }, { "name": "laravel/mcp", diff --git a/editor/src/app/ai-assistant/ai-assistant.component.ts b/editor/src/app/ai-assistant/ai-assistant.component.ts index 73a960fe3..3f413451c 100644 --- a/editor/src/app/ai-assistant/ai-assistant.component.ts +++ b/editor/src/app/ai-assistant/ai-assistant.component.ts @@ -66,7 +66,7 @@ import {
@if ((messages$ | async)?.length === 0) {

- Ask me to change design or site settings.
+ Ask me to help with your content, design, or settings.
e.g. "Make the background dark blue" or "Set the page title to My Site"

@@ -95,7 +95,7 @@ import { From fcd7d6cb8b95cc2aaaed6ec52f113f92e671d806 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Fri, 17 Apr 2026 10:35:42 +0300 Subject: [PATCH 17/21] Daily limits for ai assistant based on the plan --- .../Feature/AiAssistantQuotaServiceTest.php | 118 ++++++++++++++++++ .../ai-assistant/ai-assistant.component.ts | 22 +++- .../app/ai-assistant/ai-assistant.state.ts | 31 ++++- editor/src/app/app-state/app-state.service.ts | 6 +- 4 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 _api_app/tests/Feature/AiAssistantQuotaServiceTest.php diff --git a/_api_app/tests/Feature/AiAssistantQuotaServiceTest.php b/_api_app/tests/Feature/AiAssistantQuotaServiceTest.php new file mode 100644 index 000000000..a27fab9d8 --- /dev/null +++ b/_api_app/tests/Feature/AiAssistantQuotaServiceTest.php @@ -0,0 +1,118 @@ +makePartial(); + $user->shouldReceive('getPlan')->andReturn($plan); + $user->plans = $plans ?: [ + ['id' => '1', 'name' => 'Basic', 'features' => [], 'limits' => ['ai_assistant_daily' => 30]], + ['id' => '2', 'name' => 'Pro', 'features' => [], 'limits' => ['ai_assistant_daily' => 100]], + ['id' => '3', 'name' => 'Shop', 'features' => [], 'limits' => ['ai_assistant_daily' => 100]], + ]; + + return $user; +} + +it('returns 30 requests per day for basic plan', function () { + $service = new AiAssistantQuotaService; + + expect($service->getDailyLimit(makeUser(1)))->toBe(30); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('returns 100 requests per day for pro plan', function () { + $service = new AiAssistantQuotaService; + + expect($service->getDailyLimit(makeUser(2)))->toBe(100); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('returns 100 requests per day for shop plan', function () { + $service = new AiAssistantQuotaService; + + expect($service->getDailyLimit(makeUser(3)))->toBe(100); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('returns 0 when plan has no ai_assistant_daily limit configured', function () { + $service = new AiAssistantQuotaService; + $user = makeUser(1, [ + ['id' => '1', 'name' => 'Basic', 'features' => [], 'limits' => []], + ]); + + expect($service->getDailyLimit($user))->toBe(0); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('returns 0 when plan id is not found in plans list', function () { + $service = new AiAssistantQuotaService; + $user = makeUser(9, []); + + expect($service->getDailyLimit($user))->toBe(0); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('formats cache key with current date', function () { + $service = new AiAssistantQuotaService; + + expect($service->getCacheKey())->toBe('ai_requests:' . now()->format('Y-m-d')); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('returns zero count when no requests have been made', function () { + Cache::flush(); + $service = new AiAssistantQuotaService; + + expect($service->getCount())->toBe(0); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('increments count on each call', function () { + Cache::flush(); + $service = new AiAssistantQuotaService; + + expect($service->increment())->toBe(1) + ->and($service->increment())->toBe(2) + ->and($service->getCount())->toBe(2); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('reports limit not reached when count is below limit', function () { + Cache::flush(); + $service = new AiAssistantQuotaService; + $user = makeUser(1); // limit = 30 + + $service->increment(); // count = 1 + + expect($service->isLimitReached($user))->toBeFalse(); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('reports limit reached when count equals the daily limit', function () { + Cache::flush(); + $service = new AiAssistantQuotaService; + $user = makeUser(1); // limit = 30 + + Cache::put($service->getCacheKey(), 30); + + expect($service->isLimitReached($user))->toBeTrue(); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('reports limit reached when count exceeds the daily limit', function () { + Cache::flush(); + $service = new AiAssistantQuotaService; + $user = makeUser(1); // limit = 30 + + Cache::put($service->getCacheKey(), 31); + + expect($service->isLimitReached($user))->toBeTrue(); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); + +it('uses separate keys for different days', function () { + Cache::flush(); + $service = new AiAssistantQuotaService; + + Cache::put('ai_requests:2024-01-01', 99); + Cache::put('ai_requests:2024-01-02', 5); + + // Only the current day's key is used + expect($service->getCount())->toBe(0); +})->skip(! $pluginInstalled, 'AiAssistant plugin not installed'); diff --git a/editor/src/app/ai-assistant/ai-assistant.component.ts b/editor/src/app/ai-assistant/ai-assistant.component.ts index 3f413451c..929db9211 100644 --- a/editor/src/app/ai-assistant/ai-assistant.component.ts +++ b/editor/src/app/ai-assistant/ai-assistant.component.ts @@ -92,16 +92,20 @@ import { }
+ @if (dailyLimitMessage$ | async; as limitMessage) { +

{{ limitMessage }}

+ } @@ -279,6 +283,17 @@ import { flex-shrink: 0; } + .ai-limit-message { + margin: 0 0 0.5em; + padding: 0.5em 0.75em; + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 4px; + color: #856404; + font-size: 12px; + line-height: 1.4; + } + .ai-input-area textarea { flex-grow: 1; resize: none; @@ -348,6 +363,7 @@ export class AiAssistantComponent implements AfterViewChecked, OnDestroy { isOpen$: Observable; messages$: Observable; isLoading$: Observable; + dailyLimitMessage$: Observable; inputText = ''; isMinimized = false; @@ -363,10 +379,14 @@ export class AiAssistantComponent implements AfterViewChecked, OnDestroy { this.isOpen$ = this.store.select(AiAssistantState.isOpen); this.messages$ = this.store.select(AiAssistantState.messages); this.isLoading$ = this.store.select(AiAssistantState.isLoading); + this.dailyLimitMessage$ = this.store.select(AiAssistantState.dailyLimitMessage); this.subs.push( this.isOpen$.subscribe((open) => { if (open) this.shouldFocus = true; }), + this.store.select(AiAssistantState.pendingInput).subscribe((text) => { + if (text != null) this.inputText = text; + }), this.messages$.subscribe((msgs) => { if (msgs.length > this.messageCount) this.shouldScroll = true; this.messageCount = msgs.length; diff --git a/editor/src/app/ai-assistant/ai-assistant.state.ts b/editor/src/app/ai-assistant/ai-assistant.state.ts index 4d51eb892..0a7fa454a 100644 --- a/editor/src/app/ai-assistant/ai-assistant.state.ts +++ b/editor/src/app/ai-assistant/ai-assistant.state.ts @@ -88,6 +88,8 @@ export interface AiAssistantStateModel { isLoading: boolean; changeHistory: AiChangeHistoryItem[]; loadedSite: string | null; + dailyLimitMessage: string | null; + pendingInput: string | null; } const STORAGE_KEY_PREFIX = 'berta_ai_chat_'; @@ -98,6 +100,8 @@ const defaults: AiAssistantStateModel = { isLoading: false, changeHistory: [], loadedSite: null, + dailyLimitMessage: null, + pendingInput: null, }; @State({ @@ -121,6 +125,16 @@ export class AiAssistantState { return state.isLoading; } + @Selector() + static dailyLimitMessage(state: AiAssistantStateModel) { + return state.dailyLimitMessage; + } + + @Selector() + static pendingInput(state: AiAssistantStateModel) { + return state.pendingInput; + } + constructor( private store: Store, private aiAssistantService: AiAssistantService, @@ -177,6 +191,7 @@ export class AiAssistantState { patchState({ messages: [...state.messages, userMessage], isLoading: true, + pendingInput: null, }); const site = this.store.selectSnapshot(AppState.getSite) || ''; @@ -239,7 +254,19 @@ export class AiAssistantState { }), catchError((error) => { console.error('AI assistant error:', error); - patchState({ isLoading: false }); + if (error?.status === 429) { + const currentMessages = getState().messages; + const lastUserMessage = [...currentMessages].reverse().find(m => m.role === 'user'); + patchState({ + isLoading: false, + dailyLimitMessage: error?.error?.message ?? 'Daily limit reached.', + messages: currentMessages.slice(0, -1), + pendingInput: lastUserMessage?.content ?? null, + }); + setTimeout(() => patchState({ dailyLimitMessage: null }), 10000); + } else { + patchState({ isLoading: false }); + } return EMPTY; }), ); @@ -645,6 +672,6 @@ export class AiAssistantState { if (site != null) { localStorage.removeItem(STORAGE_KEY_PREFIX + site); } - patchState({ messages: [], changeHistory: [], isLoading: false }); + patchState({ messages: [], changeHistory: [], isLoading: false, dailyLimitMessage: null, pendingInput: null }); } } diff --git a/editor/src/app/app-state/app-state.service.ts b/editor/src/app/app-state/app-state.service.ts index f24da5ce1..794fb0cce 100644 --- a/editor/src/app/app-state/app-state.service.ts +++ b/editor/src/app/app-state/app-state.service.ts @@ -120,7 +120,11 @@ export class AppStateService { }), tap(() => this.hideLoading()), catchError((error) => { - this.showError(error, data); + // 429 responses are handled by the specific feature (e.g. AI chat panel), + // so skip the generic error notification to avoid a duplicate message. + if (!(error instanceof HttpErrorResponse) || error.status !== 429) { + this.showError(error, data); + } this.hideLoading(); throw error; }), From 03880f640d8fc3d4d0b1e51b095f9b54ea2caa55 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Fri, 17 Apr 2026 10:55:50 +0300 Subject: [PATCH 18/21] AI assistant berta badge --- editor/src/app/header/header.component.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/editor/src/app/header/header.component.ts b/editor/src/app/header/header.component.ts index f391c9597..4d933ff25 100644 --- a/editor/src/app/header/header.component.ts +++ b/editor/src/app/header/header.component.ts @@ -67,7 +67,7 @@ import { ToggleAiAssistantAction } from '../ai-assistant/ai-assistant.actions'; href="#" (click)="toggleAiAssistant($event)" [class.nav-active]="isAiOpen$ | async" - >AIAI Beta } @@ -124,6 +124,21 @@ import { ToggleAiAssistantAction } from '../ai-assistant/ai-assistant.actions'; display: inline-block; text-decoration: none; } + + header nav a .badge-beta { + display: inline-block; + font-size: 9px; + line-height: 1; + padding: 2px 4px; + border-radius: 3px; + background: #0c4dff; + color: #fff; + vertical-align: middle; + text-transform: uppercase; + letter-spacing: 0.03em; + position: relative; + top: -1px; + } `, ], standalone: false, From 75a44042fb554c9b3b1a377b70bf659dd8558843 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Thu, 23 Apr 2026 09:43:43 +0300 Subject: [PATCH 19/21] Example prompts as placeholders --- .../ai-assistant/ai-assistant.component.ts | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/editor/src/app/ai-assistant/ai-assistant.component.ts b/editor/src/app/ai-assistant/ai-assistant.component.ts index 929db9211..e0a72dc2a 100644 --- a/editor/src/app/ai-assistant/ai-assistant.component.ts +++ b/editor/src/app/ai-assistant/ai-assistant.component.ts @@ -15,6 +15,12 @@ import { ToggleAiAssistantAction, } from './ai-assistant.actions'; +const EXAMPLE_PROMPTS = [ + 'Make the background color light green', + 'Set the main heading to My Works', + 'Add a new section called Portfolio', +] as const; + @Component({ selector: 'berta-ai-assistant', template: ` @@ -65,11 +71,14 @@ import { @if (!isMinimized) {
@if ((messages$ | async)?.length === 0) { -

- Ask me to help with your content, design, or settings.
- e.g. "Make the background dark blue" or "Set the page title to - My Site" -

+
+

Ask me to help with your content, design, or settings.
e.g.

+
+ @for (prompt of examplePrompts; track prompt) { + + } +
+
} @for (msg of messages$ | async; track $index) {
; inputText = ''; isMinimized = false; + readonly examplePrompts = EXAMPLE_PROMPTS; @ViewChild('messagesContainer') private messagesContainer: ElementRef; @ViewChild('inputEl') private inputEl: ElementRef; @@ -428,6 +463,11 @@ export class AiAssistantComponent implements AfterViewChecked, OnDestroy { } } + selectExample(prompt: string) { + this.inputText = prompt; + this.send(); + } + clearChat(event: Event) { event.preventDefault(); this.store.dispatch(new ClearAiChatAction()); From 7f7bd6bc171b2fbfc1594f86b31efd286884164c Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Thu, 23 Apr 2026 10:45:43 +0300 Subject: [PATCH 20/21] AI Feedback --- _api_app/.env.example | 2 + .../app/Http/Controllers/StateController.php | 4 ++ _api_app/config/services.php | 5 ++ .../app/ai-assistant/ai-assistant.actions.ts | 5 ++ .../ai-assistant/ai-assistant.component.ts | 65 +++++++++++++++++++ .../app/ai-assistant/ai-assistant.service.ts | 11 ++++ .../app/ai-assistant/ai-assistant.state.ts | 34 ++++++++++ editor/src/app/app-state/app.state.ts | 5 ++ 8 files changed, 131 insertions(+) diff --git a/_api_app/.env.example b/_api_app/.env.example index e755a3387..e4a6b81d8 100644 --- a/_api_app/.env.example +++ b/_api_app/.env.example @@ -8,3 +8,5 @@ SENTRY_DSN= SENTRY_FRONTEND_DSN= AI_DEFAULT_PROVIDER=anthropic ANTHROPIC_API_KEY= +AI_FEEDBACK_SCRIPT_URL= +AI_FEEDBACK_SCRIPT_SECRET= diff --git a/_api_app/app/Http/Controllers/StateController.php b/_api_app/app/Http/Controllers/StateController.php index f3ebf304e..18a3198a4 100644 --- a/_api_app/app/Http/Controllers/StateController.php +++ b/_api_app/app/Http/Controllers/StateController.php @@ -51,6 +51,10 @@ public function get($site = '') if (Route::has('ai_chat')) { $state['urls']['aiChat'] = route('ai_chat'); } + + if (Route::has('ai_feedback') && config('services.ai_feedback.script_url')) { + $state['urls']['aiFeedback'] = route('ai_feedback'); + } $state['sites'] = $sitesDataService->getState(); $state['site_settings'] = []; $state['site_sections'] = []; diff --git a/_api_app/config/services.php b/_api_app/config/services.php index 27a36175f..b498e0b5c 100644 --- a/_api_app/config/services.php +++ b/_api_app/config/services.php @@ -35,4 +35,9 @@ ], ], + 'ai_feedback' => [ + 'script_url' => env('AI_FEEDBACK_SCRIPT_URL'), + 'script_secret' => env('AI_FEEDBACK_SCRIPT_SECRET'), + ], + ]; diff --git a/editor/src/app/ai-assistant/ai-assistant.actions.ts b/editor/src/app/ai-assistant/ai-assistant.actions.ts index 54cef8f76..445380979 100644 --- a/editor/src/app/ai-assistant/ai-assistant.actions.ts +++ b/editor/src/app/ai-assistant/ai-assistant.actions.ts @@ -23,3 +23,8 @@ export class AiMessageReceivedAction { export class ClearAiChatAction { static readonly type = 'AI_ASSISTANT:CLEAR'; } + +export class SubmitAiFeedbackAction { + static readonly type = 'AI_ASSISTANT:SUBMIT_FEEDBACK'; + constructor(public messageIndex: number, public vote: 'up' | 'down') {} +} diff --git a/editor/src/app/ai-assistant/ai-assistant.component.ts b/editor/src/app/ai-assistant/ai-assistant.component.ts index e0a72dc2a..6b9fd3955 100644 --- a/editor/src/app/ai-assistant/ai-assistant.component.ts +++ b/editor/src/app/ai-assistant/ai-assistant.component.ts @@ -6,6 +6,7 @@ import { OnDestroy, } from '@angular/core'; import { Observable, Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; import { Store } from '@ngxs/store'; import { AiAssistantState, AiMessage } from './ai-assistant.state'; @@ -13,7 +14,9 @@ import { SendAiMessageAction, ClearAiChatAction, ToggleAiAssistantAction, + SubmitAiFeedbackAction, } from './ai-assistant.actions'; +import { AppState } from '../app-state/app.state'; const EXAMPLE_PROMPTS = [ 'Make the background color light green', @@ -88,6 +91,32 @@ const EXAMPLE_PROMPTS = [ > @if (msg.role === 'assistant') { + @if (hasFeedback$ | async) { +
+ + +
+ } } @else { {{ msg.content }} } @@ -389,6 +418,36 @@ const EXAMPLE_PROMPTS = [ .ai-message--assistant strong { font-weight: 600; } + + .ai-feedback { + display: flex; + gap: 4px; + margin-top: 6px; + } + + .ai-feedback-btn { + background: none; + border: none; + cursor: pointer; + padding: 2px 4px; + border-radius: 3px; + color: #aaa; + display: flex; + align-items: center; + } + + .ai-feedback-btn:hover:not(:disabled) { + color: #555; + background: #e4e4e4; + } + + .ai-feedback-btn--active { + color: #333; + } + + .ai-feedback-btn:disabled { + cursor: default; + } `, ], standalone: false, @@ -398,6 +457,7 @@ export class AiAssistantComponent implements AfterViewChecked, OnDestroy { messages$: Observable; isLoading$: Observable; dailyLimitMessage$: Observable; + hasFeedback$: Observable; inputText = ''; isMinimized = false; readonly examplePrompts = EXAMPLE_PROMPTS; @@ -415,6 +475,7 @@ export class AiAssistantComponent implements AfterViewChecked, OnDestroy { this.messages$ = this.store.select(AiAssistantState.messages); this.isLoading$ = this.store.select(AiAssistantState.isLoading); this.dailyLimitMessage$ = this.store.select(AiAssistantState.dailyLimitMessage); + this.hasFeedback$ = this.store.select(AppState.getAiFeedbackUrl).pipe(map((url) => !!url)); this.subs.push( this.isOpen$.subscribe((open) => { if (open) this.shouldFocus = true; @@ -473,6 +534,10 @@ export class AiAssistantComponent implements AfterViewChecked, OnDestroy { this.store.dispatch(new ClearAiChatAction()); } + submitFeedback(messageIndex: number, vote: 'up' | 'down') { + this.store.dispatch(new SubmitAiFeedbackAction(messageIndex, vote)); + } + close() { this.store.dispatch(new ToggleAiAssistantAction()); } diff --git a/editor/src/app/ai-assistant/ai-assistant.service.ts b/editor/src/app/ai-assistant/ai-assistant.service.ts index 5f3c0ec05..32fbe2f19 100644 --- a/editor/src/app/ai-assistant/ai-assistant.service.ts +++ b/editor/src/app/ai-assistant/ai-assistant.service.ts @@ -66,4 +66,15 @@ export class AiAssistantService { .sync('aiChat', { message, history, site, template, change_history: changeHistory }, 'POST') .pipe(map((response: any) => response.data as AiChatResponse)); } + + feedback( + vote: 'up' | 'down', + userMessage: string, + assistantMessage: string, + conversation: { role: string; content: string }[], + ): Observable { + return this.appStateService + .sync('aiFeedback', { vote, user_message: userMessage, assistant_message: assistantMessage, conversation }, 'POST') + .pipe(map(() => undefined)); + } } diff --git a/editor/src/app/ai-assistant/ai-assistant.state.ts b/editor/src/app/ai-assistant/ai-assistant.state.ts index 0a7fa454a..686c398db 100644 --- a/editor/src/app/ai-assistant/ai-assistant.state.ts +++ b/editor/src/app/ai-assistant/ai-assistant.state.ts @@ -29,12 +29,14 @@ import { SendAiMessageAction, AiMessageReceivedAction, ClearAiChatAction, + SubmitAiFeedbackAction, } from './ai-assistant.actions'; import { UserLoginAction } from '../user/user.actions'; export interface AiMessage { role: 'user' | 'assistant'; content: string; + vote?: 'up' | 'down'; } export interface AiChangeItem { @@ -666,6 +668,38 @@ export class AiAssistantState { return concat(createChain, cloneChain, reorderChain, createEntryChain); } + @Action(SubmitAiFeedbackAction) + submitFeedback( + { getState, patchState }: StateContext, + action: SubmitAiFeedbackAction, + ) { + const state = getState(); + const messages = state.messages; + const assistantMessage = messages[action.messageIndex]; + + if (!assistantMessage || assistantMessage.role !== 'assistant') { + return EMPTY; + } + + const userMessage = [...messages.slice(0, action.messageIndex)].reverse().find((m) => m.role === 'user'); + const site = this.store.selectSnapshot(AppState.getSite) || ''; + + const updatedMessages = messages.map((m, i) => + i === action.messageIndex ? { ...m, vote: action.vote } : m, + ); + patchState({ messages: updatedMessages }); + this.saveToStorage(site, updatedMessages, state.changeHistory); + + return this.aiAssistantService + .feedback(action.vote, userMessage?.content ?? '', assistantMessage.content, messages.map((m) => ({ role: m.role, content: m.content }))) + .pipe( + catchError((error) => { + console.error('AI feedback error:', error); + return EMPTY; + }), + ); + } + @Action(ClearAiChatAction) clearChat({ patchState }: StateContext) { const site = this.store.selectSnapshot(AppState.getSite); diff --git a/editor/src/app/app-state/app.state.ts b/editor/src/app/app-state/app.state.ts index 4f6748d11..efd06f630 100644 --- a/editor/src/app/app-state/app.state.ts +++ b/editor/src/app/app-state/app.state.ts @@ -103,6 +103,11 @@ export class AppState implements NgxsOnInit { return state.lastRoute; } + @Selector() + static getAiFeedbackUrl(state: AppStateModel) { + return state.urls['aiFeedback'] ?? null; + } + constructor( private router: Router, private actions$: Actions, From c80dac2d39a5d41dc90a105d2790fc9505c9ae6d Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Fri, 24 Apr 2026 09:15:14 +0300 Subject: [PATCH 21/21] Daily usage quota --- .../app/Http/Controllers/StateController.php | 4 + .../app/ai-assistant/ai-assistant.actions.ts | 9 ++ .../ai-assistant/ai-assistant.component.ts | 92 +++++++++++++++---- .../app/ai-assistant/ai-assistant.service.ts | 7 ++ .../app/ai-assistant/ai-assistant.state.ts | 33 ++++++- 5 files changed, 126 insertions(+), 19 deletions(-) diff --git a/_api_app/app/Http/Controllers/StateController.php b/_api_app/app/Http/Controllers/StateController.php index 18a3198a4..bef8783e3 100644 --- a/_api_app/app/Http/Controllers/StateController.php +++ b/_api_app/app/Http/Controllers/StateController.php @@ -52,6 +52,10 @@ public function get($site = '') $state['urls']['aiChat'] = route('ai_chat'); } + if (Route::has('ai_quota')) { + $state['urls']['aiQuota'] = route('ai_quota'); + } + if (Route::has('ai_feedback') && config('services.ai_feedback.script_url')) { $state['urls']['aiFeedback'] = route('ai_feedback'); } diff --git a/editor/src/app/ai-assistant/ai-assistant.actions.ts b/editor/src/app/ai-assistant/ai-assistant.actions.ts index 445380979..4f7761c33 100644 --- a/editor/src/app/ai-assistant/ai-assistant.actions.ts +++ b/editor/src/app/ai-assistant/ai-assistant.actions.ts @@ -24,6 +24,15 @@ export class ClearAiChatAction { static readonly type = 'AI_ASSISTANT:CLEAR'; } +export class FetchAiQuotaAction { + static readonly type = 'AI_ASSISTANT:FETCH_QUOTA'; +} + +export class AiQuotaFetchedAction { + static readonly type = 'AI_ASSISTANT:QUOTA_FETCHED'; + constructor(public usage: { count: number; limit: number }) {} +} + export class SubmitAiFeedbackAction { static readonly type = 'AI_ASSISTANT:SUBMIT_FEEDBACK'; constructor(public messageIndex: number, public vote: 'up' | 'down') {} diff --git a/editor/src/app/ai-assistant/ai-assistant.component.ts b/editor/src/app/ai-assistant/ai-assistant.component.ts index 6b9fd3955..e11cad7ba 100644 --- a/editor/src/app/ai-assistant/ai-assistant.component.ts +++ b/editor/src/app/ai-assistant/ai-assistant.component.ts @@ -40,13 +40,15 @@ const EXAMPLE_PROMPTS = [ New chat } + }
@@ -100,8 +109,20 @@ const EXAMPLE_PROMPTS = [ (click)="submitFeedback($index, 'up')" aria-label="Thumbs up" > - - + +
@@ -141,12 +174,21 @@ const EXAMPLE_PROMPTS = [ rows="3" [disabled]="!!(dailyLimitMessage$ | async)" > - +
+ + @if (dailyUsage$ | async; as usage) { + {{ usage.count }} / {{ usage.limit }} daily limit + } +
} @@ -373,8 +415,18 @@ const EXAMPLE_PROMPTS = [ border-color: #999; } + .ai-actions-row { + display: flex; + align-items: center; + gap: 0.75em; + } + + .ai-usage-counter { + color: #aaa; + font-size: 11px; + } + .ai-input-area button { - align-self: flex-start; padding: 0.4em 0.8em; background: #333; color: #fff; @@ -457,6 +509,7 @@ export class AiAssistantComponent implements AfterViewChecked, OnDestroy { messages$: Observable; isLoading$: Observable; dailyLimitMessage$: Observable; + dailyUsage$: Observable<{ count: number; limit: number } | null>; hasFeedback$: Observable; inputText = ''; isMinimized = false; @@ -474,8 +527,13 @@ export class AiAssistantComponent implements AfterViewChecked, OnDestroy { this.isOpen$ = this.store.select(AiAssistantState.isOpen); this.messages$ = this.store.select(AiAssistantState.messages); this.isLoading$ = this.store.select(AiAssistantState.isLoading); - this.dailyLimitMessage$ = this.store.select(AiAssistantState.dailyLimitMessage); - this.hasFeedback$ = this.store.select(AppState.getAiFeedbackUrl).pipe(map((url) => !!url)); + this.dailyLimitMessage$ = this.store.select( + AiAssistantState.dailyLimitMessage, + ); + this.dailyUsage$ = this.store.select(AiAssistantState.dailyUsage); + this.hasFeedback$ = this.store + .select(AppState.getAiFeedbackUrl) + .pipe(map((url) => !!url)); this.subs.push( this.isOpen$.subscribe((open) => { if (open) this.shouldFocus = true; diff --git a/editor/src/app/ai-assistant/ai-assistant.service.ts b/editor/src/app/ai-assistant/ai-assistant.service.ts index 32fbe2f19..20ac44fa2 100644 --- a/editor/src/app/ai-assistant/ai-assistant.service.ts +++ b/editor/src/app/ai-assistant/ai-assistant.service.ts @@ -47,6 +47,7 @@ export interface AiChatResponse { section_changes: AiSectionChangeItem[]; entry_changes: AiEntryChangeItem[]; gallery_changes: AiGalleryChangeItem[]; + daily_usage?: { count: number; limit: number }; } @Injectable({ @@ -67,6 +68,12 @@ export class AiAssistantService { .pipe(map((response: any) => response.data as AiChatResponse)); } + getQuota(): Observable<{ count: number; limit: number }> { + return this.appStateService + .sync('aiQuota', {}, 'GET') + .pipe(map((response: any) => response.data as { count: number; limit: number })); + } + feedback( vote: 'up' | 'down', userMessage: string, diff --git a/editor/src/app/ai-assistant/ai-assistant.state.ts b/editor/src/app/ai-assistant/ai-assistant.state.ts index 686c398db..1f869ef96 100644 --- a/editor/src/app/ai-assistant/ai-assistant.state.ts +++ b/editor/src/app/ai-assistant/ai-assistant.state.ts @@ -30,6 +30,8 @@ import { AiMessageReceivedAction, ClearAiChatAction, SubmitAiFeedbackAction, + FetchAiQuotaAction, + AiQuotaFetchedAction, } from './ai-assistant.actions'; import { UserLoginAction } from '../user/user.actions'; @@ -91,6 +93,7 @@ export interface AiAssistantStateModel { changeHistory: AiChangeHistoryItem[]; loadedSite: string | null; dailyLimitMessage: string | null; + dailyUsage: { count: number; limit: number } | null; pendingInput: string | null; } @@ -103,6 +106,7 @@ const defaults: AiAssistantStateModel = { changeHistory: [], loadedSite: null, dailyLimitMessage: null, + dailyUsage: null, pendingInput: null, }; @@ -137,6 +141,11 @@ export class AiAssistantState { return state.pendingInput; } + @Selector() + static dailyUsage(state: AiAssistantStateModel) { + return state.dailyUsage; + } + constructor( private store: Store, private aiAssistantService: AiAssistantService, @@ -179,8 +188,25 @@ export class AiAssistantState { } @Action(ToggleAiAssistantAction) - toggle({ patchState, getState }: StateContext) { - patchState({ isOpen: !getState().isOpen }); + toggle({ patchState, getState, dispatch }: StateContext) { + const willOpen = !getState().isOpen; + patchState({ isOpen: willOpen }); + if (willOpen) { + dispatch(new FetchAiQuotaAction()); + } + } + + @Action(FetchAiQuotaAction) + fetchQuota({ dispatch }: StateContext) { + return this.aiAssistantService.getQuota().pipe( + tap((usage) => dispatch(new AiQuotaFetchedAction(usage))), + catchError(() => EMPTY), + ); + } + + @Action(AiQuotaFetchedAction) + quotaFetched({ patchState }: StateContext, action: AiQuotaFetchedAction) { + patchState({ dailyUsage: action.usage }); } @Action(SendAiMessageAction) @@ -250,6 +276,9 @@ export class AiAssistantState { .chat(action.message, history, site, template, changeHistoryPayload) .pipe( tap((response) => { + if (response.daily_usage) { + patchState({ dailyUsage: response.daily_usage }); + } dispatch( new AiMessageReceivedAction(response.reply, response.design_changes, response.settings_changes, response.section_changes ?? [], response.is_undo, response.entry_changes ?? [], response.gallery_changes ?? []), );