From d4f04ddbfa835cc1597385d21e06cd7de27c2586 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Fri, 19 Sep 2025 08:40:41 +0100 Subject: [PATCH 1/7] boost proper, now we can --- .ai/guidelines/actions.blade.php | 2 +- .cursor/rules/{all.mdc => laravel-boost.mdc} | 234 ++++++++++-------- .mcp.json | 11 + CLAUDE.md | 235 +++++++++++-------- composer.json | 2 +- composer.lock | 2 +- 6 files changed, 286 insertions(+), 200 deletions(-) rename .cursor/rules/{all.mdc => laravel-boost.mdc} (75%) create mode 100644 .mcp.json diff --git a/.ai/guidelines/actions.blade.php b/.ai/guidelines/actions.blade.php index 406c6d2..1abeb1b 100644 --- a/.ai/guidelines/actions.blade.php +++ b/.ai/guidelines/actions.blade.php @@ -1,6 +1,6 @@ # Laravel Action Pattern -- This application uses the Action pattern and prefers for much logic to live in reusable and composable Action classes. +- This application uses the Action pattern and business logic should be encapsulated in reusable and composable Action classes. - Actions live in `app/Actions/`, they are named based on what they do, with no suffix. - Actions will be called from many different places: jobs, commands, HTTP requests, API requests, MCP requests, and more. - Create dedicated Action classes for business logic with a single `handle()` method. diff --git a/.cursor/rules/all.mdc b/.cursor/rules/laravel-boost.mdc similarity index 75% rename from .cursor/rules/all.mdc rename to .cursor/rules/laravel-boost.mdc index 9645830..0b4d826 100644 --- a/.cursor/rules/all.mdc +++ b/.cursor/rules/laravel-boost.mdc @@ -1,53 +1,107 @@ --- -description: -globs: alwaysApply: true --- + +=== foundation rules === + +# 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. -## Conventions +## 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.4.12 +- inertiajs/inertia-laravel (INERTIA) - v2 +- laravel/framework (LARAVEL) - v12 +- laravel/mcp (MCP) - v0 +- laravel/passport (PASSPORT) - v13 +- laravel/prompts (PROMPTS) - v0 +- laravel/socialite (SOCIALITE) - v5 +- laravel/wayfinder (WAYFINDER) - v0 +- laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 +- pestphp/pest (PEST) - v4 +- phpunit/phpunit (PHPUNIT) - v12 +- @inertiajs/react (INERTIA) - v2 +- react (REACT) - v19 +- tailwindcss (TAILWINDCSS) - v4 +- @laravel/vite-plugin-wayfinder (WAYFINDER) - v0 +- eslint (ESLINT) - v9 +- prettier (PRETTIER) - v3 + +## 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. - 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. ## Application Structure & Architecture - - 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. ## Documentation Files - - You must only create documentation files if explicitly requested by the user. + +=== boost rules === + +## 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. + +## 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 +- 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. + +## 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. +- 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`. + +### 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 + + === php rules === ## PHP - Always use curly braces for control structures, even if it has one line. -- Always use `declare(strict_types=1);` at the top of PHP files. ### Constructors - - Use PHP 8 constructor property promotion in `__construct()`. - - public function \_\_construct(public GitHub $github) { } + - public function __construct(public GitHub $github) { } - Do not allow empty `__construct()` methods with zero parameters. ### Type Declarations - - Always use explicit return type declarations for methods and functions. - Use appropriate PHP type hints for method parameters. @@ -59,24 +113,23 @@ protected function isAccessible(User $user, ?string $path = null): bool ## Comments - - Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. ## 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`. -- Keys in an Enum should be uppercase. For example: `FAVORITE_PERSON`, `BEST_LAKE`, `MONTHLY`. === herd rules === ## Laravel Herd -- The application is served by Laravel Herd and will be available at: https?://locket.test. If available, use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs. +- The application is served by Laravel Herd and will be available at: https?://[kebab-case-project-dir].test. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs. - You must not run any commands to make the site available via HTTP(s). It is _always_ available through Laravel Herd. + === inertia-laravel/core rules === ## Inertia Core @@ -94,6 +147,7 @@ Route::get('/users', function () { }); + === inertia-laravel/v2 rules === ## Inertia v2 @@ -101,7 +155,6 @@ Route::get('/users', function () { - Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach. ### Inertia v2 New Features - - Polling - Prefetching - Deferred props @@ -109,15 +162,14 @@ Route::get('/users', function () { - Lazy loading data on scroll ### Deferred Props & Empty States - - When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton. ### Inertia Form General Guidance - - The recommended way to build forms when using Inertia is with the `
` component - a useful example is below. Use `search-docs` with a query of `form component` for guidance. - Forms can also be built using the `useForm` helper for more programmatic control, or to follow existing conventions. Use `search-docs` with a query of `useForm helper` for guidance. - `resetOnError`, `resetOnSuccess`, and `setDefaultsOnSuccess` are available on the `` component. Use `search-docs` with a query of 'form component resetting' for guidance. + === laravel/core rules === ## Do Things the Laravel Way @@ -127,7 +179,6 @@ Route::get('/users', function () { - 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. @@ -135,53 +186,44 @@ Route::get('/users', function () { - 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 - - 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 - - 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. ### 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. - 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. ### 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 -- Use the `search-docs` tool to get version specific documentation when available. +- Use the `search-docs` tool to get version specific documentation. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses. ### Laravel 12 Structure - - No middleware files in `app/Http/Middleware/`. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/providers.php` contains application specific service providers. @@ -189,14 +231,13 @@ Route::get('/users', function () { - **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. ### 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);`. ### 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 @@ -204,29 +245,27 @@ Route::get('/users', function () { - 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(); - }); - + +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`. @@ -234,25 +273,21 @@ Route::get('/users', function () { - 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(); + +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. @@ -264,6 +299,7 @@ it('has emails', function (string $email) { ]); + === pest/v4 rules === ## Pest 4 @@ -274,7 +310,6 @@ it('has emails', function (string $email) { - Use the `search-docs` tool for detailed guidance on utilizing these features. ### Browser Testing - - You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test. - Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test. - If requested, test on multiple browsers (Chrome, Firefox, Safari). @@ -300,7 +335,6 @@ it('may reset the password', function () { ->assertSee('We have emailed your password reset link!') Notification::assertSent(ResetPassword::class); - }); @@ -310,6 +344,7 @@ $pages = visit(['/', '/about', '/contact']); $pages->assertNoJavascriptErrors()->assertNoConsoleLogs(); + === inertia-react/core rules === ## Inertia + React @@ -319,12 +354,12 @@ $pages->assertNoJavascriptErrors()->assertNoConsoleLogs(); import { Link } from '@inertiajs/react' - Home -=== inertia-react/v2 rules === + +=== inertia-react/v2/forms rules === ## Inertia + React Forms @@ -333,20 +368,19 @@ import { Link } from '@inertiajs/react' import { Form } from '@inertiajs/react' export default () => ( - - -{({ -errors, -hasErrors, -processing, -wasSuccessful, -recentlySuccessful, -clearErrors, -resetAndClearErrors, -defaults -}) => ( -<> - + + {({ + errors, + hasErrors, + processing, + wasSuccessful, + recentlySuccessful, + clearErrors, + resetAndClearErrors, + defaults + }) => ( + <> + {errors.name &&
{errors.name}
} @@ -358,11 +392,11 @@ defaults )} - ) + === tailwindcss/core rules === ## Tailwind Core @@ -370,22 +404,24 @@ defaults - Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. - Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) - Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. ### Spacing - - When listing items, use gap utilities for spacing, don't use margins. - -
-
Superior
-
Michigan
-
Erie
-
-
-### Dark Mode + +
+
Superior
+
Michigan
+
Erie
+
+
+ +### Dark Mode - If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + === tailwindcss/v4 rules === ## Tailwind 4 @@ -394,41 +430,42 @@ defaults - `corePlugins` is not supported in Tailwind v4. - In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: - + - @tailwind base; + - @tailwind components; + - @tailwind utilities; + + @import "tailwindcss"; + -* @import "tailwindcss"; - ### Replaced Utilities - - Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. - Opacity values are still numeric. -| Deprecated | Replacement | +| Deprecated | Replacement | |------------+--------------| -| bg-opacity-_ | bg-black/_ | -| text-opacity-_ | text-black/_ | -| border-opacity-_ | border-black/_ | -| divide-opacity-_ | divide-black/_ | -| ring-opacity-_ | ring-black/_ | -| placeholder-opacity-_ | placeholder-black/_ | -| flex-shrink-_ | shrink-_ | -| flex-grow-_ | grow-_ | +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | | overflow-ellipsis | text-ellipsis | | decoration-slice | box-decoration-slice | | decoration-clone | box-decoration-clone | + === 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. -
+ + +=== .ai/actions rules === # Laravel Action Pattern @@ -458,5 +495,4 @@ public function \_\_construct(protected FavoriteApi $fav) { } } - -- Write Browser tests where appropriate. + \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..8c6715a --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "php", + "args": [ + "artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 9084b24..8319a7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,47 +1,104 @@ + +=== foundation rules === + +# 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. -## Conventions +## 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.4.12 +- inertiajs/inertia-laravel (INERTIA) - v2 +- laravel/framework (LARAVEL) - v12 +- laravel/mcp (MCP) - v0 +- laravel/passport (PASSPORT) - v13 +- laravel/prompts (PROMPTS) - v0 +- laravel/socialite (SOCIALITE) - v5 +- laravel/wayfinder (WAYFINDER) - v0 +- laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 +- pestphp/pest (PEST) - v4 +- phpunit/phpunit (PHPUNIT) - v12 +- @inertiajs/react (INERTIA) - v2 +- react (REACT) - v19 +- tailwindcss (TAILWINDCSS) - v4 +- @laravel/vite-plugin-wayfinder (WAYFINDER) - v0 +- eslint (ESLINT) - v9 +- prettier (PRETTIER) - v3 + +## 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. - 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. ## Application Structure & Architecture - - 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. ## Documentation Files - - You must only create documentation files if explicitly requested by the user. + +=== boost rules === + +## 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. + +## 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 +- 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. + +## 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. +- 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`. + +### 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 + + === php rules === ## PHP - Always use curly braces for control structures, even if it has one line. -- Always use `declare(strict_types=1);` at the top of PHP files. ### Constructors - - Use PHP 8 constructor property promotion in `__construct()`. - - public function \_\_construct(public GitHub $github) { } + - public function __construct(public GitHub $github) { } - Do not allow empty `__construct()` methods with zero parameters. ### Type Declarations - - Always use explicit return type declarations for methods and functions. - Use appropriate PHP type hints for method parameters. @@ -53,24 +110,23 @@ protected function isAccessible(User $user, ?string $path = null): bool ## Comments - - Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. ## 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`. -- Keys in an Enum should be uppercase. For example: `FAVORITE_PERSON`, `BEST_LAKE`, `MONTHLY`. === herd rules === ## Laravel Herd -- The application is served by Laravel Herd and will be available at: https?://locket.test. If available, use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs. +- The application is served by Laravel Herd and will be available at: https?://[kebab-case-project-dir].test. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs. - You must not run any commands to make the site available via HTTP(s). It is _always_ available through Laravel Herd. + === inertia-laravel/core rules === ## Inertia Core @@ -88,6 +144,7 @@ Route::get('/users', function () { }); + === inertia-laravel/v2 rules === ## Inertia v2 @@ -95,7 +152,6 @@ Route::get('/users', function () { - Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach. ### Inertia v2 New Features - - Polling - Prefetching - Deferred props @@ -103,15 +159,14 @@ Route::get('/users', function () { - Lazy loading data on scroll ### Deferred Props & Empty States - - When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton. ### Inertia Form General Guidance - - The recommended way to build forms when using Inertia is with the `
` component - a useful example is below. Use `search-docs` with a query of `form component` for guidance. - Forms can also be built using the `useForm` helper for more programmatic control, or to follow existing conventions. Use `search-docs` with a query of `useForm helper` for guidance. - `resetOnError`, `resetOnSuccess`, and `setDefaultsOnSuccess` are available on the `` component. Use `search-docs` with a query of 'form component resetting' for guidance. + === laravel/core rules === ## Do Things the Laravel Way @@ -121,7 +176,6 @@ Route::get('/users', function () { - 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. @@ -129,53 +183,44 @@ Route::get('/users', function () { - 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 - - 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 - -- Use Laravel's built-in authentication and authorization features (gates, policies, etc.). +- 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. ### 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. - 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. ### 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 -- Use the `search-docs` tool to get version specific documentation when available. +- Use the `search-docs` tool to get version specific documentation. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses. ### Laravel 12 Structure - - No middleware files in `app/Http/Middleware/`. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/providers.php` contains application specific service providers. @@ -183,14 +228,13 @@ Route::get('/users', function () { - **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. ### 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);`. ### 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 @@ -198,29 +242,27 @@ Route::get('/users', function () { - 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(); - }); - + +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`. @@ -228,25 +270,21 @@ Route::get('/users', function () { - 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(); + +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. @@ -258,6 +296,7 @@ it('has emails', function (string $email) { ]); + === pest/v4 rules === ## Pest 4 @@ -268,7 +307,6 @@ it('has emails', function (string $email) { - Use the `search-docs` tool for detailed guidance on utilizing these features. ### Browser Testing - - You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test. - Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test. - If requested, test on multiple browsers (Chrome, Firefox, Safari). @@ -294,7 +332,6 @@ it('may reset the password', function () { ->assertSee('We have emailed your password reset link!') Notification::assertSent(ResetPassword::class); - }); @@ -304,6 +341,7 @@ $pages = visit(['/', '/about', '/contact']); $pages->assertNoJavascriptErrors()->assertNoConsoleLogs(); + === inertia-react/core rules === ## Inertia + React @@ -313,12 +351,12 @@ $pages->assertNoJavascriptErrors()->assertNoConsoleLogs(); import { Link } from '@inertiajs/react' - Home -=== inertia-react/v2 rules === + +=== inertia-react/v2/forms rules === ## Inertia + React Forms @@ -327,20 +365,19 @@ import { Link } from '@inertiajs/react' import { Form } from '@inertiajs/react' export default () => ( - - -{({ -errors, -hasErrors, -processing, -wasSuccessful, -recentlySuccessful, -clearErrors, -resetAndClearErrors, -defaults -}) => ( -<> - + + {({ + errors, + hasErrors, + processing, + wasSuccessful, + recentlySuccessful, + clearErrors, + resetAndClearErrors, + defaults + }) => ( + <> + {errors.name &&
{errors.name}
} @@ -352,11 +389,11 @@ defaults )} - ) + === tailwindcss/core rules === ## Tailwind Core @@ -364,22 +401,24 @@ defaults - Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. - Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) - Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. ### Spacing - - When listing items, use gap utilities for spacing, don't use margins. - -
-
Superior
-
Michigan
-
Erie
-
-
-### Dark Mode + +
+
Superior
+
Michigan
+
Erie
+
+
+ +### Dark Mode - If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + === tailwindcss/v4 rules === ## Tailwind 4 @@ -388,41 +427,42 @@ defaults - `corePlugins` is not supported in Tailwind v4. - In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: - + - @tailwind base; + - @tailwind components; + - @tailwind utilities; + + @import "tailwindcss"; + -* @import "tailwindcss"; - ### Replaced Utilities - - Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. - Opacity values are still numeric. -| Deprecated | Replacement | +| Deprecated | Replacement | |------------+--------------| -| bg-opacity-_ | bg-black/_ | -| text-opacity-_ | text-black/_ | -| border-opacity-_ | border-black/_ | -| divide-opacity-_ | divide-black/_ | -| ring-opacity-_ | ring-black/_ | -| placeholder-opacity-_ | placeholder-black/_ | -| flex-shrink-_ | shrink-_ | -| flex-grow-_ | grow-_ | +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | | overflow-ellipsis | text-ellipsis | | decoration-slice | box-decoration-slice | | decoration-clone | box-decoration-clone | + === 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. -
+ + +=== .ai/actions rules === # Laravel Action Pattern @@ -452,5 +492,4 @@ public function \_\_construct(protected FavoriteApi $fav) { } } - -- Write Browser tests where appropriate. + \ No newline at end of file diff --git a/composer.json b/composer.json index 1a2e7a5..7f123bc 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ }, "require-dev": { "fakerphp/faker": "^1.23", - "laravel/boost": "1.2.0", + "laravel/boost": "^1.2", "laravel/pail": "^1.2.2", "laravel/pint": "^1.18", "laravel/sail": "^1.41", diff --git a/composer.lock b/composer.lock index 93cd7cd..cfb811c 100644 --- a/composer.lock +++ b/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": "9baf6cf3a8ca4b486b20bdf6073e2f0f", + "content-hash": "0c5f5eccbec6a0a9344f04fcbbdd7cdf", "packages": [ { "name": "brick/math", From cc3eb6a62740eb5a768399ba4268298011a2d7d6 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Fri, 19 Sep 2025 08:44:23 +0100 Subject: [PATCH 2/7] fix: better dark mode under trending links --- resources/js/pages/welcome.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/pages/welcome.tsx b/resources/js/pages/welcome.tsx index f56cc15..5d8c8ca 100644 --- a/resources/js/pages/welcome.tsx +++ b/resources/js/pages/welcome.tsx @@ -406,7 +406,7 @@ function TrendingLinksSection({ ))} - Locket is your social link sharing read-later app for developers + Locket is your social link sharing read-later app for developers ); } From 592a9250a296898e6163236ab2acf67326768bf7 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Fri, 19 Sep 2025 09:19:02 +0100 Subject: [PATCH 3/7] feat: add phpstan/larastan, update code to phpstan level 5 --- app/Actions/AddLink.php | 2 + app/Actions/AddLinkNote.php | 2 + app/Actions/CreateStatusWithLink.php | 2 + app/Actions/GetAllRecentStatuses.php | 2 + app/Actions/GetRecentLinks.php | 3 + app/Actions/GetTrendingLinksToday.php | 2 + app/Actions/GetUserLastAddedLink.php | 19 +- app/Console/Commands/FetchLinkTitle.php | 26 +-- .../Controllers/Auth/SocialiteController.php | 3 +- .../Settings/ProfileController.php | 31 +-- app/Http/Resources/UserStatusResource.php | 12 +- app/Mcp/Resources/LastAddedLink.php | 5 +- app/Mcp/Tools/AddLink.php | 8 +- app/Models/Link.php | 3 + app/Models/UserLink.php | 5 + app/Models/UserStatus.php | 6 + app/Providers/AppServiceProvider.php | 1 + composer.json | 1 + composer.lock | 190 +++++++++++++++++- phpstan.neon | 9 + 20 files changed, 271 insertions(+), 61 deletions(-) create mode 100644 phpstan.neon diff --git a/app/Actions/AddLink.php b/app/Actions/AddLink.php index c1ac5f6..aeebdeb 100644 --- a/app/Actions/AddLink.php +++ b/app/Actions/AddLink.php @@ -17,6 +17,8 @@ final class AddLink { /** * Add a link (or find existing) and create user bookmark. + * + * @return array{link: array{id: int, url: string, title: string, description: string, category: string}, user_link: array{id: int, category: string, status: string, created_at: string}, already_bookmarked: bool} */ public function handle(string $url, User $user, ?string $categoryHint = null): array { diff --git a/app/Actions/AddLinkNote.php b/app/Actions/AddLinkNote.php index c1dc93f..c714aef 100644 --- a/app/Actions/AddLinkNote.php +++ b/app/Actions/AddLinkNote.php @@ -14,6 +14,8 @@ final class AddLinkNote { /** * Add a personal note to a user's bookmarked link. + * + * @return array{note: array{id: int, note: string, created_at: string}, link_id: int} */ public function handle(int $linkId, string $note, User $user): array { diff --git a/app/Actions/CreateStatusWithLink.php b/app/Actions/CreateStatusWithLink.php index 23539dc..bbaeb1b 100644 --- a/app/Actions/CreateStatusWithLink.php +++ b/app/Actions/CreateStatusWithLink.php @@ -18,6 +18,8 @@ public function __construct( /** * Add a link and create a status update mentioning it. + * + * @return array{link: array{id: int, url: string, title: string, description: string, category: string}, user_link: array{id: int, category: string, status: string, created_at: string}, status: array{id: int, status: string, created_at: string}, already_bookmarked: bool, note?: array{id: int, note: string, created_at: string}} */ public function handle(string $url, ?string $thoughts, User $user, ?string $categoryHint = null): array { diff --git a/app/Actions/GetAllRecentStatuses.php b/app/Actions/GetAllRecentStatuses.php index ae25c8e..99e9722 100644 --- a/app/Actions/GetAllRecentStatuses.php +++ b/app/Actions/GetAllRecentStatuses.php @@ -14,6 +14,8 @@ final class GetAllRecentStatuses * Get recent statuses as minimal payload. * * When a user is provided, only that user's statuses are returned. + * + * @return array */ public function handle(int $limit = 10, ?User $user = null): array { diff --git a/app/Actions/GetRecentLinks.php b/app/Actions/GetRecentLinks.php index 5a79392..e504195 100644 --- a/app/Actions/GetRecentLinks.php +++ b/app/Actions/GetRecentLinks.php @@ -10,6 +10,8 @@ final class GetRecentLinks { /** * Get the most recently added links. + * + * @return array */ public function handle(int $limit = 10): array { @@ -24,6 +26,7 @@ public function handle(int $limit = 10): array 'title' => $link->title, 'description' => $link->description, 'category' => $link->category->value, + /** @phpstan-ignore-next-line nullsafe.neverNull */ 'submitted_by' => $link->submittedBy?->name ?? 'Anonymous', 'created_at' => $link->created_at->diffForHumans(), ]; diff --git a/app/Actions/GetTrendingLinksToday.php b/app/Actions/GetTrendingLinksToday.php index 12afb70..31cbcbd 100644 --- a/app/Actions/GetTrendingLinksToday.php +++ b/app/Actions/GetTrendingLinksToday.php @@ -12,6 +12,8 @@ final class GetTrendingLinksToday { /** * Get the top trending links for today based on user bookmarks. + * + * @return array */ public function handle(int $limit = 10): array { diff --git a/app/Actions/GetUserLastAddedLink.php b/app/Actions/GetUserLastAddedLink.php index 7ceff9d..35e6a8a 100644 --- a/app/Actions/GetUserLastAddedLink.php +++ b/app/Actions/GetUserLastAddedLink.php @@ -11,9 +11,12 @@ final class GetUserLastAddedLink { /** * Get the user's most recently added link with its notes. + * + * @return array{user_link: array{id: int, category: string, status: string, created_at: string}, link: array{id: int, url: string, title: string, description: string, category: string}, notes: array}|null */ public function handle(User $user): ?array { + /** @var UserLink|null $userLink */ $userLink = UserLink::with(['link', 'notes' => function ($query) use ($user) { $query->forUser($user->id)->recent(); }]) @@ -39,13 +42,15 @@ public function handle(User $user): ?array 'description' => $userLink->link->description, 'category' => $userLink->link->category->value, ], - 'notes' => $userLink->notes->map(function ($note) { - return [ - 'id' => $note->id, - 'note' => $note->note, - 'created_at' => $note->created_at->diffForHumans(), - ]; - })->toArray(), + 'notes' => $userLink->notes->map( + function (\App\Models\LinkNote $note, int $key) { + return [ + 'id' => $note->id, + 'note' => $note->note, + 'created_at' => $note->created_at->diffForHumans(), + ]; + } + )->toArray(), ]; } } diff --git a/app/Console/Commands/FetchLinkTitle.php b/app/Console/Commands/FetchLinkTitle.php index 2d0dc5b..e1c65e3 100644 --- a/app/Console/Commands/FetchLinkTitle.php +++ b/app/Console/Commands/FetchLinkTitle.php @@ -36,7 +36,7 @@ public function handle(): int if ($debug) { // Fetch the HTML directly to show what we're getting - $this->info("Fetching HTML with debug mode..."); + $this->info('Fetching HTML with debug mode...'); try { $response = \Illuminate\Support\Facades\Http::timeout(10) @@ -45,28 +45,28 @@ public function handle(): int $html = $response->body(); - $this->info("Response Status: " . $response->status()); - $this->info("Content Type: " . $response->header('Content-Type')); - $this->info("HTML Length: " . strlen($html) . " bytes"); + $this->info('Response Status: '.$response->status()); + $this->info('Content Type: '.$response->header('Content-Type')); + $this->info('HTML Length: '.strlen($html).' bytes'); // Look for title tag if (preg_match('/]*>(.*?)<\/title>/is', $html, $matches)) { - $this->info("Raw title tag content: " . $matches[1]); - $this->info("Cleaned title: " . html_entity_decode(trim($matches[1]))); + $this->info('Raw title tag content: '.$matches[1]); + $this->info('Cleaned title: '.html_entity_decode(trim($matches[1]))); } else { - $this->warn("No title tag found!"); - $this->info("First 1000 chars of HTML:"); + $this->warn('No title tag found!'); + $this->info('First 1000 chars of HTML:'); $this->line(substr($html, 0, 1000)); } $this->info("\n---Now running the actual job---\n"); } catch (\Exception $e) { - $this->error("Debug fetch failed: " . $e->getMessage()); + $this->error('Debug fetch failed: '.$e->getMessage()); } } // Create a temporary Link model (not saved to database) - $link = new Link(); + $link = new Link; $link->id = 0; $link->url = $url; $link->title = null; @@ -76,8 +76,9 @@ public function handle(): int // Override the update method to just display the result $link->fillable(['title']); - $originalUpdate = \Closure::bind(function($attributes) { + $originalUpdate = \Closure::bind(function ($attributes) { $this->fill($attributes); + return true; }, $link, Link::class); @@ -89,10 +90,11 @@ public function handle(): int if ($link->title) { $this->info("✓ Successfully fetched title: {$link->title}"); } else { - $this->warn("No title was fetched for this URL"); + $this->warn('No title was fetched for this URL'); } } catch (\Exception $e) { $this->error("Error: {$e->getMessage()}"); + return Command::FAILURE; } diff --git a/app/Http/Controllers/Auth/SocialiteController.php b/app/Http/Controllers/Auth/SocialiteController.php index fe2f073..2a58c98 100644 --- a/app/Http/Controllers/Auth/SocialiteController.php +++ b/app/Http/Controllers/Auth/SocialiteController.php @@ -8,10 +8,11 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; use Laravel\Socialite\Facades\Socialite; +use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse; class SocialiteController { - public function redirectToGitHub(): RedirectResponse + public function redirectToGitHub(): SymfonyRedirectResponse { return Socialite::driver('github')->redirect(); } diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index e9844e7..bffa300 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -24,11 +24,10 @@ public function edit(Request $request): Response ->where('revoked', false) ->orderBy('created_at', 'desc') ->get() - ->map(function ($token) { + ->map(function (\Laravel\Passport\Token $token) { return [ 'id' => $token->id, 'name' => $token->name, - 'last_used_at' => $token->last_used_at?->toDateTimeString(), 'created_at' => $token->created_at->toDateTimeString(), ]; }); @@ -83,25 +82,11 @@ public function destroy(Request $request): RedirectResponse /** * Create a new API token. */ - public function createToken(CreateApiTokenRequest $request): RedirectResponse|JsonResponse + public function createToken(CreateApiTokenRequest $request): JsonResponse|RedirectResponse { $tokenResult = $request->user()->createToken($request->validated()['name']); - // For API requests (not Inertia), return JSON - if ($request->wantsJson() && ! $request->header('X-Inertia')) { - return response()->json([ - 'token' => $tokenResult->accessToken, - 'accessToken' => [ - 'id' => $tokenResult->token->id, - 'name' => $tokenResult->token->name, - 'last_used_at' => $tokenResult->token->last_used_at, - 'created_at' => $tokenResult->token->created_at, - ], - ]); - } - - // For Inertia and regular web requests, store token in session then redirect - // Using session instead of flash because flash doesn't always work with Inertia redirects + // For web/Inertia requests, store token in session then redirect session(['created_token' => $tokenResult->accessToken]); return back(); @@ -110,24 +95,16 @@ public function createToken(CreateApiTokenRequest $request): RedirectResponse|Js /** * Revoke an API token. */ - public function revokeToken(Request $request, string $tokenId): RedirectResponse|JsonResponse + public function revokeToken(Request $request, string $tokenId): JsonResponse|RedirectResponse { $token = $request->user()->tokens()->where('id', $tokenId)->first(); if (! $token) { - if ($request->wantsJson()) { - return response()->json(['error' => 'Token not found'], 404); - } - return back()->withErrors(['token' => 'Token not found or could not be revoked.']); } $token->revoke(); - if ($request->wantsJson()) { - return response()->json(['message' => 'Token revoked successfully']); - } - return back()->with('message', 'Token revoked successfully.'); } } diff --git a/app/Http/Resources/UserStatusResource.php b/app/Http/Resources/UserStatusResource.php index 0ae3b7d..e2b446e 100644 --- a/app/Http/Resources/UserStatusResource.php +++ b/app/Http/Resources/UserStatusResource.php @@ -20,14 +20,14 @@ class UserStatusResource extends JsonResource */ public function toArray(Request $request): array { - $email = strtolower(trim($this->user?->email ?? '')); + $email = strtolower(trim($this->user->email)); $hash = md5($email); $gravatar = "https://www.gravatar.com/avatar/{$hash}?s=128&d=404"; $fallback = 'https://avatars.laravel.cloud/'.urlencode($email).'?vibe=stealth'; // Use GitHub avatar if available, otherwise fall back to Gravatar - $avatar = $this->user?->avatar ?? $gravatar; - $displayName = $this->user?->github_username ?? $this->user?->name ?? 'Unknown'; + $avatar = $this->user->avatar ?? $gravatar; + $displayName = $this->user->github_username ?? $this->user->name ?? 'Unknown'; return [ 'id' => $this->id, @@ -35,16 +35,16 @@ public function toArray(Request $request): array 'created_at' => $this->created_at?->toAtomString(), 'user' => [ 'name' => $displayName, - 'github_username' => $this->user?->github_username, + 'github_username' => $this->user->github_username ?? null, 'avatar' => $avatar, 'avatar_fallback' => $fallback, ], - 'link' => $this->link ? [ + 'link' => [ 'id' => $this->link->id, 'url' => $this->link->url, 'title' => $this->link->title, 'description' => $this->link->description, - ] : null, + ], ]; } } diff --git a/app/Mcp/Resources/LastAddedLink.php b/app/Mcp/Resources/LastAddedLink.php index beb620e..875ea14 100644 --- a/app/Mcp/Resources/LastAddedLink.php +++ b/app/Mcp/Resources/LastAddedLink.php @@ -14,11 +14,12 @@ class LastAddedLink extends Resource public function handle(Request $request, GetUserLastAddedLink $getUserLastAddedLink): string { - if (! $request->user()) { + $user = $request->user(); + if (! $user instanceof \App\Models\User) { return "❌ **Authentication Required**\n\nYou must be authenticated to view your last added link."; } - $result = $getUserLastAddedLink->handle($request->user()); + $result = $getUserLastAddedLink->handle($user); if (! $result) { return "⚠️ **No Links Found**\n\nYou haven't added any links to your Locket yet. Try adding your first link!"; diff --git a/app/Mcp/Tools/AddLink.php b/app/Mcp/Tools/AddLink.php index ea3983c..6289f4a 100644 --- a/app/Mcp/Tools/AddLink.php +++ b/app/Mcp/Tools/AddLink.php @@ -33,7 +33,7 @@ public function handle(Request $request): Response $user = $request->user(); - if (! $user) { + if (! $user instanceof \App\Models\User) { return Response::error('Authentication required to add links'); } @@ -78,12 +78,10 @@ public function schema(JsonSchema $schema): array ->description('The URL to add to your reading list') ->required(), 'thoughts' => $schema->string() - ->description('Optional thoughts or notes about this link (will be saved as a private note)') - ->required(false), + ->description('Optional thoughts or notes about this link (will be saved as a private note)'), 'category_hint' => $schema->string() ->enum(['read', 'reference', 'watch', 'tools']) - ->description('Optional category hint: read (articles/blogs), reference (docs/specs), watch (videos), tools (libraries/services)') - ->required(false), + ->description('Optional category hint: read (articles/blogs), reference (docs/specs), watch (videos), tools (libraries/services)'), ]; } } diff --git a/app/Models/Link.php b/app/Models/Link.php index 3543add..90c9c82 100644 --- a/app/Models/Link.php +++ b/app/Models/Link.php @@ -13,6 +13,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +/** + * @property-read User|null $submittedBy + */ class Link extends Model { /** @use HasFactory<\Database\Factories\LinkFactory> */ diff --git a/app/Models/UserLink.php b/app/Models/UserLink.php index a11c22c..49c4d7d 100644 --- a/app/Models/UserLink.php +++ b/app/Models/UserLink.php @@ -13,6 +13,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +/** + * @property-read Link $link + * @property-read User $user + * @property-read \Illuminate\Database\Eloquent\Collection $notes + */ class UserLink extends Model { /** @use HasFactory<\Database\Factories\UserLinkFactory> */ diff --git a/app/Models/UserStatus.php b/app/Models/UserStatus.php index 86d805d..6a90e34 100644 --- a/app/Models/UserStatus.php +++ b/app/Models/UserStatus.php @@ -9,6 +9,10 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +/** + * @property-read User $user + * @property-read Link $link + */ class UserStatus extends Model { /** @use HasFactory<\Database\Factories\UserStatusFactory> */ @@ -41,6 +45,8 @@ public function link(): BelongsTo /** * Convert this status to its frontend representation. + * + * @return array */ public function toFrontendFormat(): array { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 44a4b36..b1c5ba0 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -35,6 +35,7 @@ public function boot(): void /** * @param array{client: \Laravel\Passport\Client, user: \App\Models\User, scopes: array, request: \Illuminate\Http\Request, authToken: string} $parameters */ + /** @phpstan-ignore-next-line */ Passport::authorizationView(function ($parameters) { return view('auth.authorize', $parameters); }); diff --git a/composer.json b/composer.json index 7f123bc..899f2c9 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ }, "require-dev": { "fakerphp/faker": "^1.23", + "larastan/larastan": "^3.0", "laravel/boost": "^1.2", "laravel/pail": "^1.2.2", "laravel/pint": "^1.18", diff --git a/composer.lock b/composer.lock index cfb811c..1d915fc 100644 --- a/composer.lock +++ b/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": "0c5f5eccbec6a0a9344f04fcbbdd7cdf", + "content-hash": "40d5b643219c05317b33e3c863e0c410", "packages": [ { "name": "brick/math", @@ -9107,6 +9107,47 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "iamcal/sql-parser", + "version": "v0.6", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/947083e2dca211a6f12fb1beb67a01e387de9b62", + "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.6" + }, + "time": "2025-03-17T16:59:46+00:00" + }, { "name": "jean85/pretty-package-versions", "version": "2.1.1", @@ -9225,6 +9266,95 @@ }, "time": "2023-02-03T21:26:53+00:00" }, + { + "name": "larastan/larastan", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "2e653fd19585a825e283b42f38378b21ae481cc7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/2e653fd19585a825e283b42f38378b21ae481cc7", + "reference": "2e653fd19585a825e283b42f38378b21ae481cc7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "iamcal/sql-parser": "^0.6.0", + "illuminate/console": "^11.44.2 || ^12.4.1", + "illuminate/container": "^11.44.2 || ^12.4.1", + "illuminate/contracts": "^11.44.2 || ^12.4.1", + "illuminate/database": "^11.44.2 || ^12.4.1", + "illuminate/http": "^11.44.2 || ^12.4.1", + "illuminate/pipeline": "^11.44.2 || ^12.4.1", + "illuminate/support": "^11.44.2 || ^12.4.1", + "php": "^8.2", + "phpstan/phpstan": "^2.1.23" + }, + "require-dev": { + "doctrine/coding-standard": "^13", + "laravel/framework": "^11.44.2 || ^12.7.2", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1", + "orchestra/testbench-core": "^9.12.0 || ^10.1", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2025-09-10T19:42:11+00:00" + }, { "name": "laravel/boost", "version": "v1.2.0", @@ -10771,6 +10901,64 @@ }, "time": "2025-08-30T15:50:23+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.1.27", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "25da374959afa391992792691093550b3098ef1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/25da374959afa391992792691093550b3098ef1e", + "reference": "25da374959afa391992792691093550b3098ef1e", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-09-17T09:55:13+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "12.3.5", diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..b8d5923 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,9 @@ +includes: + - vendor/larastan/larastan/extension.neon + - vendor/nesbot/carbon/extension.neon + +parameters: + paths: + - app/ + + level: 5 From 0d337b737f65e58922d4ec9d9a4f96a7c288e046 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Fri, 19 Sep 2025 09:23:32 +0100 Subject: [PATCH 4/7] fix eslint issues --- resources/js/components/delete-user.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/delete-user.tsx index fa03993..d13d0b6 100644 --- a/resources/js/components/delete-user.tsx +++ b/resources/js/components/delete-user.tsx @@ -1,10 +1,7 @@ import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import HeadingSmall from '@/components/heading-small'; -import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; import { Form } from '@inertiajs/react'; import { useRef } from 'react'; From 77e79c6080a953cad81b664c6e75b3b0b455e678 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Fri, 19 Sep 2025 09:31:35 +0100 Subject: [PATCH 5/7] improve feature tests --- .../Settings/PassportTokenManagementTest.php | 35 +++++++------------ tests/Feature/UserStatusResourceTest.php | 19 +++++++--- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/tests/Feature/Settings/PassportTokenManagementTest.php b/tests/Feature/Settings/PassportTokenManagementTest.php index 0857732..655c2e4 100644 --- a/tests/Feature/Settings/PassportTokenManagementTest.php +++ b/tests/Feature/Settings/PassportTokenManagementTest.php @@ -14,23 +14,13 @@ $user = User::factory()->create(); $this->actingAs($user); - $response = $this->postJson(route('profile.tokens.create'), [ + $response = $this->post(route('profile.tokens.create'), [ 'name' => 'Test Token', ]); - $response->assertSuccessful(); - $response->assertJsonStructure([ - 'token', - 'accessToken' => [ - 'id', - 'name', - 'last_used_at', - 'created_at', - ], - ]); - - expect($response->json('accessToken.name'))->toBe('Test Token'); - expect($user->tokens()->count())->toBe(1); + $response->assertRedirect(); + expect($user->fresh()->tokens()->count())->toBe(1); + expect($user->fresh()->tokens()->first()->name)->toBe('Test Token'); }); it('can list personal access tokens', function () { @@ -60,10 +50,9 @@ $this->actingAs($user); - $response = $this->deleteJson(route('profile.tokens.revoke', $token->token->id)); + $response = $this->delete(route('profile.tokens.revoke', $token->token->id)); - $response->assertSuccessful(); - $response->assertJson(['message' => 'Token revoked successfully']); + $response->assertRedirect(); // Check token is revoked $user->refresh(); @@ -78,22 +67,22 @@ $this->actingAs($user1); - $response = $this->deleteJson(route('profile.tokens.revoke', $token->token->id)); + $response = $this->delete(route('profile.tokens.revoke', $token->token->id)); - $response->assertNotFound(); - $response->assertJson(['error' => 'Token not found']); + $response->assertRedirect(); + $response->assertSessionHasErrors('token'); }); it('validates token name when creating', function () { $user = User::factory()->create(); $this->actingAs($user); - $response = $this->postJson(route('profile.tokens.create'), [ + $response = $this->post(route('profile.tokens.create'), [ 'name' => '', ]); - $response->assertUnprocessable(); - $response->assertJsonValidationErrors('name'); + $response->assertRedirect(); + $response->assertSessionHasErrors('name'); }); it('can use personal access token to access API', function () { diff --git a/tests/Feature/UserStatusResourceTest.php b/tests/Feature/UserStatusResourceTest.php index 474c664..57c6037 100644 --- a/tests/Feature/UserStatusResourceTest.php +++ b/tests/Feature/UserStatusResourceTest.php @@ -38,17 +38,26 @@ ->and($result['user']['avatar_fallback'])->toContain('avatars.laravel.cloud'); }); -it('handles missing user gracefully', function () { - $status = UserStatus::factory()->make([ +it('handles user without github username', function () { + $user = User::factory()->create([ + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + 'github_username' => null, + 'avatar' => null, + ]); + + $status = UserStatus::factory()->create([ + 'user_id' => $user->id, 'status' => 'Test status', - 'user_id' => null, ]); - $status->user = null; + + $status->load('user'); $resource = new UserStatusResource($status); $result = $resource->toArray(new Request); - expect($result['user']['name'])->toBe('Unknown') + expect($result['user']['name'])->toBe('Jane Doe') + ->and($result['user']['github_username'])->toBeNull() ->and($result['user']['avatar'])->toContain('gravatar.com/avatar') ->and($result['user']['avatar_fallback'])->toContain('avatars.laravel.cloud'); }); From cf03864f740b0eed6bc460c18dab64179155f97b Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Fri, 19 Sep 2025 09:33:12 +0100 Subject: [PATCH 6/7] Can we run in parallel? --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5db46ce..85ce946 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,4 +47,4 @@ jobs: run: php artisan key:generate - name: Tests - run: ./vendor/bin/pest + run: ./vendor/bin/pest --parallel From 8487bf39da61d8fd76050f4632e8b3dd797a3239 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Fri, 19 Sep 2025 09:35:46 +0100 Subject: [PATCH 7/7] ci: add caching for dependencies --- .github/workflows/lint.yml | 14 ++++++++++++++ .github/workflows/tests.yml | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9f2ccc0..8be1719 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,6 +23,20 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.4' + tools: composer:v2 + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: vendor + key: composer-${{ hashFiles('composer.lock') }} + restore-keys: composer- + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' - name: Install Dependencies run: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 85ce946..1d753c2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,6 +31,13 @@ jobs: node-version: '22' cache: 'npm' + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: vendor + key: composer-${{ hashFiles('composer.lock') }} + restore-keys: composer- + - name: Install Node Dependencies run: npm ci