initial commmit
tests / ci (push) Canceled after 0s

This commit is contained in:
Mark Gregory
2026-09-30 14:24:42 +01:00
parent 4bff627a79
commit 0a74f5b44e
241 changed files with 27923 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4
+65
View File
@@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
CHANGELOG.md export-ignore
README.md export-ignore
.github/workflows/browser-tests.yml export-ignore
+12
View File
@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 5
groups:
github-actions:
patterns:
- "*"
+38
View File
@@ -0,0 +1,38 @@
name: tests
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: '8.3'
tools: composer:v2
coverage: none
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- name: Setup Application
run: composer setup
- name: Run CI Checks
run: composer ci:check
+28
View File
@@ -0,0 +1,28 @@
/.phpunit.cache
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
/auth.json
/.fleet
/.idea
/.nova
/.vscode
/.zed
.DS_Store
*/.DS_Store
**/.DS_Store
/provision/
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"laravel-boost": {
"command": "/Applications/ServBay/package/php/8.3/8.3.30/bin/php",
"args": [
"/Users/mag/Sites/Tools/standard_install/artisan",
"boost:mcp"
]
}
}
}
+81
View File
@@ -0,0 +1,81 @@
---
name: fluxui-development
description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling."
license: MIT
metadata:
author: laravel
---
# Flux UI Development
## Documentation
Use `search-docs` for detailed Flux UI patterns and documentation.
## Basic Usage
This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components.
Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize.
Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs.
<!-- Basic Button -->
```blade
<flux:button variant="primary">Click me</flux:button>
```
## Available Components (Free Edition)
Available: avatar, badge, brand, breadcrumbs, button, callout, card, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, pagination, profile, progress, radio, select, separator, skeleton, switch, table, text, textarea, toast, tooltip
## Icons
Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names.
<!-- Icon Button -->
```blade
<flux:button icon="arrow-down-tray">Export</flux:button>
```
For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command:
```bash
php artisan flux:icon crown grip-vertical github
```
## Common Patterns
### Form Fields
<!-- Form Field -->
```blade
<flux:field>
<flux:label>Email</flux:label>
<flux:input type="email" wire:model="email" />
<flux:error name="email" />
</flux:field>
```
### Modals
<!-- Modal -->
```blade
<flux:modal wire:model="showModal">
<flux:heading>Title</flux:heading>
<p>Content</p>
</flux:modal>
```
## Verification
1. Check component renders correctly
2. Test interactive states
3. Verify mobile responsiveness
## Common Pitfalls
- Trying to use Pro-only components in the free edition
- Not checking if a Flux component exists before creating custom implementations
- Forgetting to use the `search-docs` tool for component-specific documentation
- Not following existing project patterns for Flux usage
+151
View File
@@ -0,0 +1,151 @@
---
name: fortify-development
description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), passkeys, profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, passkeys, WebAuthn, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
license: MIT
metadata:
author: laravel
---
# Laravel Fortify Development
Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
## Documentation
Use `search-docs` for detailed Laravel Fortify patterns and documentation.
## Usage
- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
## Available Features
Enable in `config/fortify.php` features array:
- `Features::registration()` - User registration
- `Features::resetPasswords()` - Password reset via email
- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
- `Features::updateProfileInformation()` - Profile updates
- `Features::updatePasswords()` - Password changes
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
- `Features::passkeys()` - Passwordless authentication with WebAuthn passkeys
> Use `search-docs` for feature configuration options and customization patterns.
## Setup Workflows
### Two-Factor Authentication Setup
```
- [ ] Add TwoFactorAuthenticatable trait to User model
- [ ] Enable feature in config/fortify.php
- [ ] If the `*_add_two_factor_columns_to_users_table.php` migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
- [ ] Set up view callbacks in FortifyServiceProvider
- [ ] Create 2FA management UI
- [ ] Test QR code and recovery codes
```
> Use `search-docs` for TOTP implementation and recovery code handling patterns.
### Passkeys Setup
```
- [ ] Add PasskeyAuthenticatable trait to User model and implement PasskeyUser
- [ ] Enable passkeys feature in config/fortify.php
- [ ] If the passkeys table migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
- [ ] Configure passkeys relying_party_id, allowed_origins, user_handle_secret, and timeout if defaults are not suitable
- [ ] Build UI with @laravel/passkeys for registration, login, confirmation, and deletion
```
> Use `search-docs` for passkey configuration options. For `@laravel/passkeys` frontend usage, refer to the package's README on npm.
### Email Verification Setup
```
- [ ] Enable emailVerification feature in config
- [ ] Implement MustVerifyEmail interface on User model
- [ ] Set up verifyEmailView callback
- [ ] Add verified middleware to protected routes
- [ ] Test verification email flow
```
> Use `search-docs` for MustVerifyEmail implementation patterns.
### Password Reset Setup
```
- [ ] Enable resetPasswords feature in config
- [ ] Set up requestPasswordResetLinkView callback
- [ ] Set up resetPasswordView callback
- [ ] Define password.reset named route (if views disabled)
- [ ] Test reset email and link flow
```
> Use `search-docs` for custom password reset flow patterns.
### SPA Authentication Setup
```
- [ ] Set 'views' => false in config/fortify.php
- [ ] Install and configure Laravel Sanctum for session-based SPA authentication
- [ ] Use the 'web' guard in config/fortify.php (required for session-based authentication)
- [ ] Set up CSRF token handling
- [ ] Test XHR authentication flows
```
> Use `search-docs` for integration and SPA authentication patterns.
#### Two-Factor Authentication in SPA Mode
When `views` is set to `false`, Fortify returns JSON responses instead of redirects.
If a user attempts to log in and two-factor authentication is enabled, the login request will return a JSON response indicating that a two-factor challenge is required:
```json
{
"two_factor": true
}
```
## Best Practices
### Custom Authentication Logic
Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
### Registration Customization
Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
### Rate Limiting
Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
## Key Endpoints
| Feature | Method | Endpoint |
|------------------------|----------|---------------------------------------------|
| Login | POST | `/login` |
| Logout | POST | `/logout` |
| Register | POST | `/register` |
| Password Reset Request | POST | `/forgot-password` |
| Password Reset | POST | `/reset-password` |
| Email Verify Notice | GET | `/email/verify` |
| Resend Verification | POST | `/email/verification-notification` |
| Password Confirm | POST | `/user/confirm-password` |
| Enable 2FA | POST | `/user/two-factor-authentication` |
| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
| 2FA Challenge | POST | `/two-factor-challenge` |
| Get QR Code | GET | `/user/two-factor-qr-code` |
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
| Passkey Login Options | GET | `/passkeys/login/options` |
| Passkey Login | POST | `/passkeys/login` |
| Passkey Confirm Options| GET | `/passkeys/confirm/options` |
| Passkey Confirm | POST | `/passkeys/confirm` |
| Passkey Options | GET | `/user/passkeys/options` |
| Register Passkey | POST | `/user/passkeys` |
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |
+104
View File
@@ -0,0 +1,104 @@
---
name: infer-conventions
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
license: MIT
metadata:
author: laravel
---
# Infer Conventions
Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it.
## Ground Rules (read before you start)
- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer.
- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record.
- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule.
- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering.
- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped.
- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar.
- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details.
## Process
Each step ends on a checkable completion criterion. Do not advance until it holds.
Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output.
### Step 0: Orient
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
This app ships a frontend stack, so the frontend checklist group applies. Sweep it.
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
### Step 1: Predefined sweep
Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict:
- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files.
- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled.
- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention.
- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most).
- Tooling-owned or Already-recorded. Skip per the ground rules.
Done when: every applicable dimension carries exactly one of those verdicts.
### Step 2: Open-ended pass
First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude.
Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal.
Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none).
### Step 3: Confirm
Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style.
Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo.
Done when: every candidate is approved, rejected, or (conflicts) decided.
### Step 4: Record
Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand.
Record this:
> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models.
Not this:
> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models.
Done when: every approved item has a successful tool response, and any failure is reported with its rule text.
### Step 5: Summarize
List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions.
## Glob mapping
Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path.
Examples:
- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one.
- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer.
- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses.
- Tests: `tests/**`.
- Migrations and database: `database/migrations/**`.
- Truly app-wide (rare, e.g. auth retrieval): `app/**`.
`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there.
## Edge cases
- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4.
- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing.
- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything.
- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface.
- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths.
@@ -0,0 +1,141 @@
# Detection Checklist
Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`).
Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence.
---
## A. Validation & HTTP input
1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
- Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`.
2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
- Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
- Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
- Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
## B. Controllers & routing
5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method.
- Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes.
6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs.
- Hint: read a few controller methods; `ls app/Actions app/Services`.
7. Route handler style: closures in `routes/*.php` vs controller classes.
- Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
- Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
- Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
- Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files.
## C. Authorization
11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
- Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
- Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
## D. Eloquent & models
13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list.
- Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`.
14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain.
- Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`.
15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`.
- Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`.
16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings.
- Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models.
17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`).
- Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built.
18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes.
- Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`.
19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
- Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
- Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
## E. Architecture & organization
21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked.
- Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find.
22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere.
- Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`.
23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location.
- Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps.
24. Decoupling: events + listeners vs direct service calls.
- Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`.
25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`).
- Hint: ratio of `config(` vs `Config::` (etc.) across `app/`.
26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules).
- Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders.
27. Enums: backed vs pure; case naming; where they live.
- Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`.
## F. Frontend & views
This app ships a frontend stack, so the items below apply.
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
30. Livewire component format: Volt functional/class components, native Livewire 4 single-file (SFC), multi-file (MFC), view-based, or class-based components. Evaluate full-page vs nested separately because it is an independent usage choice.
- Hint: check the installed Livewire major and `livewire/volt`; inspect `app/Livewire`, `resources/views/livewire`, and Livewire 4 component/page directories for `@volt`, SFC, MFC, view-based, and class-based formats.
31. UI kit (Flux): Flux components vs custom components vs another library.
- Hint: grep `<flux:` in `resources/views`.
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
## G. Database & migrations
33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
- Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
34. `down()` methods: real reverse logic vs omitted / one-way migrations.
- Hint: grep `function down` vs the migration count.
35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
- Hint: grep `->enum(` in migrations vs string columns cast to enums.
36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
- Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`.
## H. Testing
38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes.
- Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`.
39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`.
- Hint: grep those trait names in `tests/`.
40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
- Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
- Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
## I. Responses & API resources
43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
- Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers.
44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
- Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
- Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them.
46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`.
- Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views.
## J. Strings, collections & dates
47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
- Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
- Hint: grep `Str::of(` vs `Str::` vs native string funcs.
49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
- Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy.
---
Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from.
@@ -0,0 +1,59 @@
---
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, organized as an index of rule files. Each rule file 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, and 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.
## How to Apply
1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out.
2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files.
3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job.
4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable.
5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them.
6. Re-read the diff against every mapped rule before finishing.
## Rule Index
Cross-cutting changes often need more than one rule file.
| Concern | Read |
| --- | --- |
| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) |
| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) |
| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) |
| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) |
| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) |
| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) |
| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) |
| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) |
| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) |
| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) |
| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) |
| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) |
| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) |
| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) |
| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) |
| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) |
| Environment values and application configuration | [`rules/config.md`](rules/config.md) |
| Pest/PHPUnit patterns, factories, fakes | [`rules/testing.md`](rules/testing.md) |
| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) |
| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) |
## Decision Rules
- Prefer framework features and existing application abstractions over new helpers or dependencies.
- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable.
- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization.
@@ -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)
);
}
```
@@ -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 handle(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. Without an explicit `ORDER BY`, row order is undefined.
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);
}
}
```
@@ -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
<div {{ $attributes->merge(['class' => 'alert alert-'.$type]) }}>
{{ $message }}
</div>
```
## 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.
@@ -0,0 +1,70 @@
# Caching Best Practices
## Use `Cache::remember()` Instead of Manual Get/Put
Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
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']],
```
@@ -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 {}
```
@@ -0,0 +1,73 @@
# Configuration Best Practices
## `env()` Only in Config Files
Direct `env()` calls may 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'));
```
@@ -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
```
@@ -0,0 +1,150 @@
# 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
#[Scope]
protected function active(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
#[Scope]
protected function published(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.
@@ -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];
}
}
```
@@ -0,0 +1,52 @@
# 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 — call `afterCommit()` to delay dispatch until the transaction commits.
```php
$user->notify((new InvoicePaid($invoice))->afterCommit());
```
## 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.
@@ -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 (Throwable $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(),
]);
```
@@ -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 fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
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.
@@ -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']);
```
@@ -0,0 +1,144 @@
# 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 `ShouldBeUniqueUntilProcessing` for Early Lock Release
`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
```php
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
// Lock releases when processing begins, not when it finishes
}
```
## 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,
],
],
],
```
@@ -0,0 +1,99 @@
# 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);
// In routes/api.php — the /api prefix is applied automatically
Route::apiResource('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');
}
```
@@ -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');
});
```
@@ -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(UpdatePostRequest $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. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field.
Incorrect:
```blade
<form method="POST" action="/posts">
<input type="text" name="title">
</form>
```
Correct:
```blade
<form method="POST" action="/posts">
@csrf
<input type="text" name="title">
</form>
```
## 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 extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. 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',
];
}
}
```
@@ -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
<button class="js-fav-article" data-article='@json($article)'>{{ $article->name }}</button>
```
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())
```
@@ -0,0 +1,43 @@
# Testing Best Practices
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
## 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();
```
@@ -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.');
}
},
];
}
```
+175
View File
@@ -0,0 +1,175 @@
---
name: livewire-development
description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire."
license: MIT
metadata:
author: laravel
---
# Livewire Development
## Documentation
Use `search-docs` for detailed Livewire 4 patterns and documentation.
## Basic Usage
### Creating Components
```bash
# Single-file component (SFC - default in v4)
# Creates: resources/views/components/⚡create-post.blade.php
php artisan make:livewire create-post
# Page component (SFC - Full Page in v4)
# Creates: resources/views/pages/⚡create-post.blade.php
php artisan make:livewire pages::create-post
# Multi-file component (MFC)
# Creates: resources/views/components/⚡create-post/create-post.php
# resources/views/components/⚡create-post/create-post.blade.php
php artisan make:livewire create-post --mfc
# Class-based component (v3 style)
# Creates: app/Livewire/CreatePost.php AND resources/views/livewire/create-post.blade.php
php artisan make:livewire create-post --class
# With namespace
php artisan make:livewire Posts/CreatePost
```
### Converting Between Formats
Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats.
### Choosing a Component Format
> **Always follow the project's existing conventions first.** Before creating any component, inspect the project's existing Livewire components to determine the established format (SFC, MFC, or class-based) and directory structure. Check `app/Livewire/`, `resources/views/components/`, and `resources/views/livewire/` for existing components. If the project already uses a consistent format, **use that same format** — even if it differs from the Livewire v4 defaults below. Only fall back to the v4 defaults (SFC in `resources/views/components/`) when no existing convention is established.
Also check `config/livewire.php` for `make_command.type`, `make_command.emoji`, `component_locations`, and `component_namespaces` overrides, which change the default format and where files are stored.
### Component Format Reference
| Format | Flag | Class Path | View Path |
|--------|------|------------|-----------|
| Single-file (SFC) | default | — | `resources/views/components/⚡create-post.blade.php` (PHP + Blade in one file) |
| Full Page SFC | `pages::name` | — | `resources/views/pages/⚡create-post.blade.php` |
| Multi-file (MFC) | `--mfc` | `resources/views/components/⚡create-post/create-post.php` | `resources/views/components/⚡create-post/create-post.blade.php` |
| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` |
| View-based | default (Blade-only) | — | `resources/views/components/⚡create-post.blade.php` (Blade-only with functional state) |
> **Important:** The ⚡ prefix shown above is the **default** behavior in Livewire v4 — it is **configurable**. Check `config/livewire.php` for the `make_command.emoji` setting. When `true` (default), always include the ⚡ prefix in filenames you create. When `false`, omit the ⚡ prefix from all paths above.
Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates `resources/views/components/posts/⚡create-post.blade.php` (single-file by default). Use `make:livewire Posts/CreatePost --mfc` for multi-file output at `resources/views/components/posts/⚡create-post/create-post.php` and `resources/views/components/posts/⚡create-post/create-post.blade.php`.
### Single-File Component Example
<!-- Single-File Component Example -->
```php
<?php
use Livewire\Component;
new class extends Component {
public int $count = 0;
public function increment(): void
{
$this->count++;
}
};
?>
<div>
<button wire:click="increment">Count: @{{ $count }}</button>
</div>
```
## Livewire 4 Specifics
### Key Changes From Livewire 3
These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions.
- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`.
- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`.
- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed).
- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`.
### New Features
- Component formats: single-file (SFC), multi-file (MFC), view-based components.
- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution.
- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading.
| Feature | Usage | Purpose |
|---------|-------|---------|
| Islands | `@island(name: 'stats')` | Isolated update regions |
| Async | `wire:click.async` or `#[Async]` | Non-blocking actions |
| Deferred | `defer` attribute | Load after page render |
| Bundled | `lazy.bundle` | Load multiple together |
### New Directives
- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use.
- `data-loading` attribute automatically added to elements triggering network requests.
| Directive | Purpose |
|-----------|---------|
| `wire:sort` | Drag-and-drop sorting |
| `wire:intersect` | Viewport intersection detection |
| `wire:ref` | Element references for JS |
| `.renderless` | Component without rendering |
| `.preserve-scroll` | Preserve scroll position |
## Best Practices
- Always use `wire:key` in loops
- Use `wire:loading` for loading states
- Use `wire:model.live` for instant updates (default is debounced)
- Validate and authorize in actions (treat like HTTP requests)
## Configuration
- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`.
## Alpine & JavaScript
- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available.
- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance.
For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md).
## Testing
<!-- Testing Example -->
```php
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1);
```
## Verification
1. Browser console: Check for JS errors
2. Network tab: Verify Livewire requests return 200
3. Ensure `wire:key` on all `@foreach` loops
## Common Pitfalls
- Missing `wire:key` in loops → unexpected re-rendering
- Expecting `wire:model` real-time → use `wire:model.live`
- Unclosed component tags → syntax errors in v4
- Using deprecated config keys or JS hooks
- Including Alpine.js separately (already bundled in Livewire 4)
@@ -0,0 +1,39 @@
# Livewire 4 JavaScript Integration
## Interceptor System (v4)
### Intercept Messages
```js
Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => {
onFinish(() => { /* After response, before processing */ });
onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ });
onError(() => { /* Server errors */ });
});
```
### Intercept Requests
```js
Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => {
onResponse(({ response }) => { /* When received */ });
onSuccess(({ response, responseJson }) => { /* Success */ });
onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ });
onFailure(({ error }) => { /* Network failures */ });
});
```
### Component-Scoped Interceptors
```blade
<script>
this.$intercept('save', ({ component, onSuccess }) => {
onSuccess(() => console.log('Saved!'));
});
</script>
```
## Magic Properties
- `$errors` - Access validation errors from JavaScript
- `$intercept` - Component-scoped interceptors
+166
View File
@@ -0,0 +1,166 @@
---
name: pest-testing
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, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
license: MIT
metadata:
author: laravel
---
# Pest Testing 4
## Documentation
Use `search-docs` for detailed Pest 4 patterns and documentation.
## Basic Usage
### Creating Tests
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
The `{name}` argument should include only the path and test name, but should not include the test suite.
- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php`
- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php`
- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php`
- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php`
### Test Organization
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
- Browser tests: `tests/Browser/` directory.
- Do NOT remove tests without approval - these are core application code.
### 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()`.
<!-- Basic Pest Test Example -->
```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()`:
<!-- Pest Response Assertion -->
```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.):
<!-- Pest Dataset Example -->
```php
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
```
## Pest 4 Features
| Feature | Purpose |
|---------|---------|
| Browser Testing | Full integration tests in real browsers |
| Smoke Testing | Validate multiple pages quickly |
| Visual Regression | Compare screenshots for visual changes |
| Test Sharding | Parallel CI runs |
| Architecture Testing | Enforce code conventions |
### Browser Test Example
Browser tests run in real browsers for full integration testing:
- Browser tests live in `tests/Browser/`.
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
- Use `RefreshDatabase` for clean state per test.
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging.
<!-- Pest Browser Test Example -->
```php
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in');
$page->assertSee('Sign In')
->assertNoJavaScriptErrors()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!');
Notification::assertSent(ResetPassword::class);
});
```
### Smoke Testing
Quickly validate multiple pages have no JavaScript errors:
<!-- Pest Smoke Testing Example -->
```php
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
```
### Visual Regression Testing
Capture and compare screenshots to detect visual changes.
### Test Sharding
Split tests across parallel processes for faster CI runs.
### Architecture Testing
Pest 4 includes architecture testing (from Pest 3):
<!-- Architecture Test Example -->
```php
arch('controllers')
->expect('App\Http\Controllers')
->toExtendNothing()
->toHaveSuffix('Controller');
```
## 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
- Forgetting `assertNoJavaScriptErrors()` in browser tests
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
@@ -0,0 +1,119 @@
---
name: tailwindcss-development
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| 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-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
+2
View File
@@ -0,0 +1,2 @@
ignore-scripts=true
audit=true
+29
View File
@@ -0,0 +1,29 @@
<?php
return [
'default_theme' => env('STYLIST_DEFAULT_THEME', 'backend'),
'default_backend_theme' => env('STYLIST_BACKEND_THEME', 'backend'),
'default_frontend_theme' => env('STYLIST_FRONTEND_THEME', 'frontend'),
/*
| Themes are application resources, never module resources.
| Every theme is self-contained and owns its views and assets.
*/
'themes_path' => env('STYLIST_THEMES_PATH', resource_path('themes')),
'tenant_theme_column' => 'theme_key',
'tenant_brand_columns' => [
'brand_primary' => 'brand-primary',
'brand_secondary' => 'brand-secondary',
'brand_accent' => 'brand-accent',
],
'fallback_variables' => [
'brand-primary' => '#2563eb',
'brand-secondary' => '#0f172a',
'brand-accent' => '#22c55e',
'surface' => '#ffffff',
'surface-muted' => '#f8fafc',
'text-primary' => '#0f172a',
],
];
@@ -0,0 +1,64 @@
<?php
namespace Modules\Stylist\Console;
use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;
final class CreateThemeCommand extends Command
{
protected $signature = 'stylist:create-theme {key : Lowercase theme key} {--parent=common : Parent theme key} {--name= : Display name}';
protected $description = 'Create a minimal child theme without copying shared views.';
public function __construct(private readonly Filesystem $files)
{
parent::__construct();
}
public function handle(): int
{
$key = strtolower(trim((string) $this->argument('key')));
if (! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $key)) {
$this->error('Theme keys may contain lowercase letters, numbers, dashes, and underscores only.');
return self::FAILURE;
}
$path = rtrim((string) config('stylist.themes_path'), '/\\').DIRECTORY_SEPARATOR.$key;
if ($this->files->exists($path)) {
$this->error("Theme [{$key}] already exists.");
return self::FAILURE;
}
$parent = trim((string) $this->option('parent'));
$name = trim((string) ($this->option('name') ?: str($key)->headline()));
$this->files->ensureDirectoryExists($path.'/views');
$manifest = [
'manifest_version' => 1,
'name' => $name,
'slug' => $key,
'version' => '1.0.0',
'description' => "Child theme for {$name}.",
'extends' => $parent !== '' ? $parent : null,
'entry_view' => 'home',
'views' => true,
'variables' => [],
'settings' => [],
];
if ($manifest['extends'] === null) {
unset($manifest['extends']);
}
$this->files->put($path.'/theme.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL);
$this->files->put($path.'/views/.gitkeep', '');
$this->info("Theme [{$key}] created at {$path}.");
$this->line('Shared views will resolve through its parent; only add genuine overrides.');
return self::SUCCESS;
}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\Stylist\Console;
use Illuminate\Console\Command;
use Modules\Stylist\Diagnostics\ThemeInspector;
final class FindDuplicateViewsCommand extends Command
{
protected $signature = 'stylist:duplicates';
protected $description = 'Find Blade task paths owned by more than one theme.';
public function __construct(private readonly ThemeInspector $inspector)
{
parent::__construct();
}
public function handle(): int
{
$duplicates = $this->inspector->duplicateViews();
if ($duplicates === []) {
$this->info('No duplicate theme views found.');
return self::SUCCESS;
}
$rows = [];
foreach ($duplicates as $view => $owners) {
foreach ($owners as $owner) {
$rows[] = [$view, $owner['theme'], $owner['path']];
}
}
$this->table(['View', 'Theme', 'Path'], $rows);
$this->error(count($duplicates).' duplicate view task(s) found.');
return self::FAILURE;
}
}
@@ -0,0 +1,28 @@
<?php
namespace Modules\Stylist\Console;
use Illuminate\Console\Command;
use Modules\Stylist\Diagnostics\ThemeInspector;
final class InspectThemesCommand extends Command
{
protected $signature = 'stylist:inspect';
protected $description = 'Display installed themes, inheritance, entry views, and view counts.';
public function __construct(private readonly ThemeInspector $inspector)
{
parent::__construct();
}
public function handle(): int
{
$themes = $this->inspector->themes();
$this->table(['Key', 'Name', 'Parent', 'Entry view', 'Views', 'Path'], array_map(fn (array $theme): array => [
$theme['key'], $theme['name'], $theme['parent'] ?? '—', $theme['entry_view'], $theme['views'], $theme['path'],
], $themes));
return self::SUCCESS;
}
}
@@ -0,0 +1,34 @@
<?php
namespace Modules\Stylist\Console;
use Illuminate\Console\Command;
final class PublishAssetsCommand extends Command
{
protected $signature = 'stylist:publish-assets {--force : Overwrite the published configuration}';
protected $description = 'Publish the Stylist configuration and ensure resources/themes exists.';
public function handle(): int
{
$force = (bool) $this->option('force');
$this->call('vendor:publish', [
'--tag' => 'stylist-config',
'--force' => $force,
]);
$themesPath = (string) config('stylist.themes_path', resource_path('themes'));
if (! is_dir($themesPath) && ! mkdir($themesPath, 0775, true) && ! is_dir($themesPath)) {
$this->components->error("Unable to create theme directory: {$themesPath}");
return self::FAILURE;
}
$this->components->info("Stylist config published. Themes remain in: {$themesPath}");
return self::SUCCESS;
}
}
@@ -0,0 +1,38 @@
<?php
namespace Modules\Stylist\Console;
use Illuminate\Console\Command;
use Modules\Stylist\Diagnostics\ThemeInspector;
final class ThemeDoctorCommand extends Command
{
protected $signature = 'stylist:doctor';
protected $description = 'Diagnose manifests, inheritance, duplicate views, and theme configuration.';
public function __construct(private readonly ThemeInspector $inspector)
{
parent::__construct();
}
public function handle(): int
{
$issues = $this->inspector->issues();
if ($issues === []) {
$this->info('Stylist is healthy. No theme issues found.');
return self::SUCCESS;
}
$this->table(['Severity', 'Theme', 'Message'], array_map(fn (array $issue): array => [
strtoupper($issue['severity']), $issue['theme'], $issue['message'],
], $issues));
$errors = count(array_filter($issues, fn (array $issue): bool => $issue['severity'] === 'error'));
$warnings = count($issues) - $errors;
$this->line("{$errors} error(s), {$warnings} warning(s).");
return $errors > 0 ? self::FAILURE : self::SUCCESS;
}
}
@@ -0,0 +1,108 @@
<?php
namespace Modules\Stylist\Console;
use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Services\ThemeValidator;
use Modules\Stylist\Theme\Loader;
use RuntimeException;
use ZipArchive;
final class ValidateThemeCommand extends Command
{
protected $signature = 'stylist:validate {theme : Installed theme key, theme directory, or theme ZIP path}';
protected $description = 'Validate a Stylist theme manifest, files, references, and accessibility.';
public function __construct(
private readonly Filesystem $files,
private readonly Loader $loader,
private readonly ThemeValidator $validator,
) {
parent::__construct();
}
public function handle(): int
{
$temporary = null;
try {
[$path, $temporary] = $this->resolveThemePath((string) $this->argument('theme'));
$theme = $this->loader->fromPath($path);
$issues = $this->validator->validate($theme);
$this->info("Theme: {$theme->name()} ({$theme->key()})");
$this->line('Manifest schema: '.$theme->manifestVersion());
if ($issues === []) {
$this->info('Theme validation passed with no issues.');
return self::SUCCESS;
}
$this->table(
['Severity', 'File', 'Message'],
array_map(fn (array $issue) => [
strtoupper($issue['severity']),
$issue['file'],
$issue['message'],
], $issues),
);
$errors = count(array_filter($issues, fn (array $issue) => $issue['severity'] === 'error'));
$warnings = count($issues) - $errors;
$this->line("{$errors} error(s), {$warnings} warning(s).");
return $errors > 0 ? self::FAILURE : self::SUCCESS;
} catch (\Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
} finally {
if ($temporary !== null) {
$this->files->deleteDirectory($temporary);
}
}
}
/** @return array{0:string,1:?string} */
private function resolveThemePath(string $input): array
{
if ($this->files->isDirectory($input)) {
return [realpath($input) ?: $input, null];
}
if ($this->files->isFile($input) && strtolower(pathinfo($input, PATHINFO_EXTENSION)) === 'zip') {
$temporary = storage_path('app/stylist/validate-'.bin2hex(random_bytes(8)));
$this->files->ensureDirectoryExists($temporary);
$zip = new ZipArchive;
if ($zip->open($input) !== true) {
throw new RuntimeException('The theme ZIP could not be opened.');
}
for ($index = 0; $index < $zip->numFiles; $index++) {
$name = (string) $zip->getNameIndex($index);
if (str_contains($name, '..') || str_starts_with($name, '/') || str_contains($name, "\0")) {
$zip->close();
throw new RuntimeException('The ZIP contains an unsafe path.');
}
}
$zip->extractTo($temporary);
$zip->close();
$manifest = collect($this->files->allFiles($temporary))
->first(fn ($file) => $file->getFilename() === 'theme.json');
if (! $manifest) {
throw new RuntimeException('The package does not contain theme.json.');
}
return [$manifest->getPath(), $temporary];
}
return [$this->loader->find($input)->path(), null];
}
}
@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('companies')) {
return;
}
Schema::table('companies', function (Blueprint $table): void {
if (! Schema::hasColumn('companies', 'theme_key')) {
$table->string('theme_key')->default('default')->after('timezone');
}
if (! Schema::hasColumn('companies', 'brand_primary')) {
$table->string('brand_primary')->nullable()->after('theme_key');
}
if (! Schema::hasColumn('companies', 'brand_secondary')) {
$table->string('brand_secondary')->nullable()->after('brand_primary');
}
if (! Schema::hasColumn('companies', 'brand_accent')) {
$table->string('brand_accent')->nullable()->after('brand_secondary');
}
if (! Schema::hasColumn('companies', 'logo_path')) {
$table->string('logo_path')->nullable()->after('brand_accent');
}
if (! Schema::hasColumn('companies', 'theme_settings')) {
$table->json('theme_settings')->nullable()->after('logo_path');
}
});
}
public function down(): void
{
if (! Schema::hasTable('companies')) {
return;
}
Schema::table('companies', function (Blueprint $table): void {
foreach (['theme_settings', 'logo_path', 'brand_accent', 'brand_secondary', 'brand_primary', 'theme_key'] as $column) {
if (Schema::hasColumn('companies', $column)) {
$table->dropColumn($column);
}
}
});
}
};
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('stylist_theme_settings', function (Blueprint $table) {
$table->id();
$table->string('theme_key')->unique();
$table->string('variant')->default('light');
$table->json('settings')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('stylist_theme_settings');
}
};
@@ -0,0 +1,147 @@
<?php
namespace Modules\Stylist\Diagnostics;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Theme;
final class ThemeInspector
{
public function __construct(
private readonly Filesystem $files,
private readonly Loader $loader,
) {}
/** @return array<int, array<string, mixed>> */
public function themes(): array
{
return $this->loader->all()->map(function (Theme $theme): array {
return [
'key' => $theme->key(),
'name' => $theme->name(),
'parent' => $theme->parent(),
'path' => $theme->path(),
'views' => count($this->viewFiles($theme)),
'entry_view' => $theme->entryView(),
];
})->values()->all();
}
/** @return array<string, array<int, array{theme:string,path:string}>> */
public function duplicateViews(): array
{
$ownership = [];
foreach ($this->loader->all() as $theme) {
foreach ($this->viewFiles($theme) as $relative => $absolute) {
$ownership[$relative][] = [
'theme' => $theme->key(),
'path' => $absolute,
];
}
}
return array_filter($ownership, fn (array $owners): bool => count($owners) > 1);
}
/** @return array<int, array{severity:string,theme:string,message:string}> */
public function issues(): array
{
$issues = [];
$themes = $this->loader->all();
foreach ($themes as $theme) {
$parent = $theme->parent();
if ($parent !== null && ! $themes->has($parent)) {
$issues[] = [
'severity' => 'error',
'theme' => $theme->key(),
'message' => "Parent theme [{$parent}] is not installed.",
];
}
if ($this->hasInheritanceCycle($theme, $themes->all())) {
$issues[] = [
'severity' => 'error',
'theme' => $theme->key(),
'message' => 'Theme inheritance contains a cycle.',
];
}
$entry = $theme->path().'/views/'.str_replace('.', '/', $theme->entryView()).'.blade.php';
if (! $this->files->exists($entry) && $parent === null) {
$issues[] = [
'severity' => 'warning',
'theme' => $theme->key(),
'message' => "Entry view [{$theme->entryView()}] does not exist in this theme.",
];
}
}
foreach ($this->duplicateViews() as $view => $owners) {
$issues[] = [
'severity' => 'error',
'theme' => implode(', ', array_column($owners, 'theme')),
'message' => "View [{$view}] has multiple owners.",
];
}
$default = (string) config('stylist.default_theme');
if ($default !== '' && ! $themes->has($default)) {
$issues[] = [
'severity' => 'warning',
'theme' => $default,
'message' => 'Configured default theme is not installed.',
];
}
return $issues;
}
/** @return array<string, string> */
private function viewFiles(Theme $theme): array
{
$root = $theme->path().'/views';
if (! $this->files->isDirectory($root)) {
return [];
}
$views = [];
foreach ($this->files->allFiles($root) as $file) {
if (! str_ends_with($file->getFilename(), '.blade.php')) {
continue;
}
$absolute = str_replace('\\', '/', $file->getPathname());
$relative = ltrim(str_replace(str_replace('\\', '/', $root), '', $absolute), '/');
$views[$relative] = $absolute;
}
ksort($views);
return $views;
}
/** @param array<string, Theme> $themes */
private function hasInheritanceCycle(Theme $theme, array $themes): bool
{
$seen = [];
$key = $theme->key();
while (isset($themes[$key])) {
if (isset($seen[$key])) {
return true;
}
$seen[$key] = true;
$key = $themes[$key]->parent() ?? '';
if ($key === '') {
return false;
}
}
return false;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Stylist\Facades;
use Illuminate\Support\Facades\Facade;
final class StylistFacade extends Facade
{
protected static function getFacadeAccessor(): string
{
return 'stylist';
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Stylist\Facades;
use Illuminate\Support\Facades\Facade;
final class ThemeFacade extends Facade
{
protected static function getFacadeAccessor(): string
{
return 'stylist.html';
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
use Illuminate\Contracts\View\View;
use Illuminate\Support\HtmlString;
use Modules\Stylist\Html\ThemeHtmlBuilder;
use Modules\Stylist\Theme\Stylist;
use Modules\Stylist\Theme\UrlGenerator;
use Modules\Stylist\View\ThemeViewResolver;
if (! function_exists('stylist')) {
function stylist(): Stylist
{
return app(Stylist::class);
}
}
if (! function_exists('stylist_html')) {
function stylist_html(): ThemeHtmlBuilder
{
return app(ThemeHtmlBuilder::class);
}
}
if (! function_exists('stylist_view')) {
function stylist_view(string $view, array $data = [], array $mergeData = []): View
{
return app(ThemeViewResolver::class)->make($view, $data, $mergeData);
}
}
if (! function_exists('stylist_theme_view')) {
function stylist_theme_view(string $theme, string $view, array $data = [], array $mergeData = []): View
{
return app(ThemeViewResolver::class)->makeForTheme($theme, $view, $data, $mergeData);
}
}
if (! function_exists('stylist_frontend_view')) {
function stylist_frontend_view(string $view, array $data = [], array $mergeData = []): View
{
return app(ThemeViewResolver::class)->makeForTheme(stylist()->frontend()->key(), $view, $data, $mergeData);
}
}
if (! function_exists('stylist_backend_view')) {
function stylist_backend_view(string $view, array $data = [], array $mergeData = []): View
{
return app(ThemeViewResolver::class)->makeForTheme(stylist()->backend()->key(), $view, $data, $mergeData);
}
}
if (! function_exists('theme_asset')) {
function theme_asset(string $path): string
{
return app(UrlGenerator::class)->asset($path);
}
}
if (! function_exists('theme_switcher')) {
function theme_switcher(): HtmlString
{
return stylist_html()->switcher();
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace Modules\Stylist\Html;
use Illuminate\Support\HtmlString;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Stylist;
use Modules\Stylist\Theme\Theme;
use Modules\Stylist\Theme\UrlGenerator;
final class ThemeHtmlBuilder
{
public function __construct(private readonly Stylist $stylist, private readonly UrlGenerator $url, private readonly Loader $loader) {}
public function stylesheet(?string $path = null): HtmlString
{
if ($path !== null) {
$theme = $this->stylist->current();
if (! $theme->has($path)) {
return new HtmlString('');
}
return new HtmlString('<link rel="stylesheet" href="'.e($this->url->forTheme($theme, $path)).'">');
}
$tags = [];
foreach (array_reverse($this->themeChain()) as $theme) {
$stylesheet = $theme->stylesheet();
if ($stylesheet !== '' && $theme->has($stylesheet)) {
$tags[] = '<link rel="stylesheet" href="'.e($this->url->forTheme($theme, $stylesheet)).'">';
}
}
return new HtmlString(implode('', $tags));
}
public function script(?string $path = null): HtmlString
{
if ($path !== null) {
$theme = $this->stylist->current();
if (! $theme->has($path)) {
return new HtmlString('');
}
return new HtmlString('<script src="'.e($this->url->forTheme($theme, $path)).'" defer></script>');
}
$tags = [];
foreach (array_reverse($this->themeChain()) as $theme) {
$script = $theme->script();
if ($script !== '' && $theme->has($script)) {
$tags[] = '<script src="'.e($this->url->forTheme($theme, $script)).'" defer></script>';
}
}
return new HtmlString(implode('', $tags));
}
public function variables(): HtmlString
{
$theme = $this->stylist->current();
$default = $this->declarations($this->stylist->variables());
$rules = [':root{'.$default.'}'];
foreach ($theme->appearances() as $name => $definition) {
$vars = (array) data_get($definition, 'variables', []);
if ($vars) {
$rules[] = ':root[data-appearance="'.$this->selector($name).'"]{'.$this->declarations($vars).'}';
}
}
foreach ($theme->accents() as $name => $definition) {
$vars = (array) data_get($definition, 'variables', []);
if ($vars) {
$rules[] = ':root[data-accent="'.$this->selector($name).'"]{'.$this->declarations($vars).'}';
}
}
if ($theme->appearances() === [] && $theme->accents() === []) {
return new HtmlString('<style>'.implode('', $rules).'</style>');
}
$appearance = json_encode($this->stylist->appearance(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
$accent = json_encode($this->stylist->accent(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
$themeKey = json_encode($theme->key(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
$hasAppearances = $theme->appearances() !== [] ? 'true' : 'false';
$hasAccents = $theme->accents() !== [] ? 'true' : 'false';
$script = <<<JS
<script>(function(){var d=document.documentElement,k={$themeKey},ha={$hasAppearances},hc={$hasAccents},a=ha?(localStorage.getItem('stylist:'+k+':appearance')||{$appearance}):'',c=hc?(localStorage.getItem('stylist:'+k+':accent')||{$accent}):'';function apply(){d.dataset.theme=k;if(ha){var dark=a==='dark'||((a==='auto'||a==='system')&&matchMedia('(prefers-color-scheme: dark)').matches);d.dataset.appearance=dark?'dark':'light';d.classList.toggle('dark',dark)}if(hc){d.dataset.accent=c}}apply();window.StylistTheme={setAppearance:function(v){if(!ha)return;a=v;localStorage.setItem('stylist:'+k+':appearance',v);apply()},setAccent:function(v){if(!hc)return;c=v;localStorage.setItem('stylist:'+k+':accent',v);apply()},appearance:function(){return a},accent:function(){return c}};if(ha){matchMedia('(prefers-color-scheme: dark)').addEventListener?.('change',function(){if(a==='auto'||a==='system')apply()})}document.dispatchEvent(new CustomEvent('stylist:ready',{detail:window.StylistTheme}))})();</script>
JS;
return new HtmlString('<style>'.implode('', $rules).'</style>'.$script);
}
public function switcher(): HtmlString
{
$theme = $this->stylist->current();
$appearances = array_keys($theme->appearances());
$accents = array_keys($theme->accents());
if ($appearances === [] && $accents === []) {
return new HtmlString('');
}
$appearanceOptions = collect($appearances)->map(fn ($name) => '<option value="'.e($name).'">'.e(ucfirst($name)).'</option>')->implode('');
$accentOptions = collect($accents)->map(fn ($name) => '<option value="'.e($name).'">'.e(ucfirst($name)).'</option>')->implode('');
$appearanceSelect = $appearances ? '<label>Appearance <select data-stylist-appearance>'.$appearanceOptions.'</select></label>' : '';
$accentSelect = $accents ? '<label>Accent <select data-stylist-accent>'.$accentOptions.'</select></label>' : '';
return new HtmlString('<div data-stylist-switcher class="stylist-theme-switcher">'.$appearanceSelect.$accentSelect.'</div><script>document.addEventListener("stylist:ready",function(){document.querySelectorAll("[data-stylist-switcher]").forEach(function(root){var a=root.querySelector("[data-stylist-appearance]"),c=root.querySelector("[data-stylist-accent]");if(a){a.value=StylistTheme.appearance();a.addEventListener("change",function(){StylistTheme.setAppearance(a.value)})}if(c){c.value=StylistTheme.accent();c.addEventListener("change",function(){StylistTheme.setAccent(c.value)})}})});</script>');
}
/** @return array<int, Theme> */
private function themeChain(): array
{
$chain = [];
$theme = $this->stylist->current();
$seen = [];
while (! isset($seen[$theme->key()])) {
$seen[$theme->key()] = true;
$chain[] = $theme;
$parent = $theme->parent();
if (! $parent) {
break;
}
try {
$theme = $this->loader->find($parent);
} catch (\Throwable) {
break;
}
}
return $chain;
}
private function declarations(array $variables): string
{
return collect($variables)
->filter(fn ($value, $key) => is_string($key) && (is_string($value) || is_numeric($value)))
->map(fn ($value, string $key) => '--'.ltrim($key, '-').': '.e((string) $value).';')
->implode('');
}
private function selector(string $value): string
{
return preg_replace('/[^a-z0-9_-]/i', '', $value) ?: 'default';
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace Modules\Stylist\Html;
use Illuminate\Support\HtmlString;
use Modules\Stylist\Theme\Stylist;
use Modules\Stylist\Theme\UrlGenerator;
final class ThemeSeoBuilder
{
public function __construct(private readonly Stylist $stylist, private readonly UrlGenerator $urls) {}
public function render(array $overrides = []): HtmlString
{
$theme = $this->stylist->current();
$seo = array_replace($theme->seo(), $overrides);
$settings = $this->stylist->settings();
$title = (string) ($seo['title'] ?? $settings['site_title'] ?? $theme->name());
$description = (string) ($seo['description'] ?? $settings['site_description'] ?? $theme->description());
$image = $seo['image'] ?? $theme->preview();
if ($image && $theme->has($image)) {
$image = $this->urls->asset($image);
}
$tags = ['<title>'.e($title).'</title>'];
if ($description !== '') {
$tags[] = '<meta name="description" content="'.e($description).'">';
}
foreach (['og:title' => $title, 'og:description' => $description, 'twitter:card' => 'summary_large_image', 'twitter:title' => $title, 'twitter:description' => $description] as $property => $content) {
if ($content === '') {
continue;
}$attribute = str_starts_with($property, 'og:') ? 'property' : 'name';
$tags[] = '<meta '.$attribute.'="'.e($property).'" content="'.e($content).'">';
}
if ($image) {
$tags[] = '<meta property="og:image" content="'.e($image).'">';
$tags[] = '<meta name="twitter:image" content="'.e($image).'">';
}
if (isset($seo['json_ld']) && is_array($seo['json_ld'])) {
$tags[] = '<script type="application/ld+json">'.json_encode($seo['json_ld'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE).'</script>';
}
return new HtmlString(implode("\n", $tags));
}
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\Stylist\Http\Controllers;
use Illuminate\Routing\Controller;
use Modules\Settings\Services\SettingsRepository;
use Modules\Stylist\Services\ThemeAccessibilityValidator;
use Modules\Stylist\Services\ThemeSettings;
use Modules\Stylist\Theme\Stylist;
use Modules\Stylist\Theme\UrlGenerator;
final class StylistController extends Controller
{
public function __invoke(Stylist $stylist, ThemeSettings $settings, ThemeAccessibilityValidator $accessibility, UrlGenerator $urls, SettingsRepository $siteSettings)
{
return view('stylist::index', [
'theme' => $stylist->current(), 'themes' => $stylist->all(), 'variables' => $stylist->variables(),
'settingsService' => $settings, 'accessibility' => $accessibility, 'urls' => $urls,
'activeFrontendTheme' => $siteSettings->get('frontend_theme', config('stylist.default_frontend_theme', 'frontend'), 'site'),
'activeBackendTheme' => $siteSettings->get('backend_theme', config('stylist.default_backend_theme', 'backend'), 'site'),
]);
}
}
@@ -0,0 +1,64 @@
<?php
namespace Modules\Stylist\Http\Controllers;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Modules\Stylist\Theme\Loader;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
final class ThemeAssetController extends Controller
{
private const ALLOWED_ROOTS = ['css', 'js', 'images', 'fonts', 'icons', 'assets'];
private const ALLOWED_EXTENSIONS = [
'css', 'js', 'mjs', 'map', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif',
'ico', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'json', 'xml', 'txt', 'webmanifest',
];
public function __invoke(string $theme, string $path, Loader $loader): BinaryFileResponse|Response
{
abort_if(str_contains($path, "\0"), 404);
$normalised = str_replace('\\', '/', ltrim($path, '/'));
$root = explode('/', $normalised, 2)[0] ?? '';
$extension = strtolower(pathinfo($normalised, PATHINFO_EXTENSION));
abort_unless(in_array($root, self::ALLOWED_ROOTS, true), 404);
abort_unless(in_array($extension, self::ALLOWED_EXTENSIONS, true), 404);
$loadedTheme = $loader->find($theme);
$themeRoot = realpath($loadedTheme->path());
abort_unless($themeRoot !== false, 404);
$asset = realpath($themeRoot.DIRECTORY_SEPARATOR.$normalised);
abort_unless(
$asset !== false
&& str_starts_with($asset, $themeRoot.DIRECTORY_SEPARATOR)
&& is_file($asset),
404
);
$mime = match ($extension) {
'css' => 'text/css; charset=UTF-8',
'js', 'mjs' => 'application/javascript; charset=UTF-8',
'svg' => 'image/svg+xml',
'json', 'map', 'webmanifest' => 'application/json',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ttf' => 'font/ttf',
'otf' => 'font/otf',
default => mime_content_type($asset) ?: 'application/octet-stream',
};
return response()->file($asset, [
'Content-Type' => $mime,
'Cache-Control' => app()->isProduction()
? 'public, max-age=31536000, immutable'
: 'no-cache, private',
'X-Content-Type-Options' => 'nosniff',
'Content-Security-Policy' => "default-src 'none'; style-src 'unsafe-inline'",
]);
}
}
@@ -0,0 +1,87 @@
<?php
namespace Modules\Stylist\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\Settings\Services\SettingsRepository;
use Modules\Stylist\Services\ThemeAccessibilityValidator;
use Modules\Stylist\Services\ThemeOptimizer;
use Modules\Stylist\Services\ThemePackageManager;
use Modules\Stylist\Services\ThemeSettings;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\View\ThemeViewResolver;
final class ThemeManagementController extends Controller
{
public function activate(string $theme, Loader $loader, SettingsRepository $settings)
{
$item = $loader->find($theme);
abort_unless(in_array($item->type(), ['frontend', 'backend'], true), 422, 'Only frontend and backend themes can be activated.');
$settings->put($item->type().'_theme', $item->key(), 'site', 'string', true);
return back()->with('status', $item->name().' is now the active '.$item->type().' theme.');
}
public function preview(string $theme, Loader $loader, ThemeViewResolver $views)
{
$item = $loader->find($theme);
return $views->makeForTheme($item->key(), $item->entryView(), [
'themePreview' => true,
'previewTheme' => $item,
]);
}
public function settings(Request $request, string $theme, Loader $loader, ThemeSettings $settings)
{
$item = $loader->find($theme);
$settings->save(
$item,
(array) $request->input('settings', []),
(string) $request->input('variant', 'light'),
$request->string('appearance')->toString() ?: null,
$request->string('accent')->toString() ?: null,
);
return back()->with('status', 'Theme settings saved.');
}
public function install(Request $request, ThemePackageManager $packages)
{
$request->validate(['theme' => ['required', 'file', 'mimes:zip', 'max:51200']]);
$theme = $packages->install($request->file('theme'));
return back()->with('status', $theme->name().' installed.');
}
public function uninstall(string $theme, Loader $loader, ThemePackageManager $packages)
{
$item = $loader->find($theme);
$packages->uninstall($item);
return back()->with('status', $item->name().' uninstalled.');
}
public function export(string $theme, Loader $loader, ThemePackageManager $packages)
{
$item = $loader->find($theme);
$path = $packages->export($item);
return response()->download($path)->deleteFileAfterSend();
}
public function optimize(string $theme, Loader $loader, ThemeOptimizer $optimizer)
{
$item = $loader->find($theme);
$result = $optimizer->optimize($item);
return back()->with('status', "Optimised {$result['css']} CSS, {$result['js']} JS and {$result['images']} images.");
}
public function validateTheme(string $theme, Loader $loader, ThemeAccessibilityValidator $validator)
{
return response()->json(['issues' => $validator->validate($loader->find($theme))]);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Modules\Stylist\Livewire;
use Livewire\Component;
final class ThemePreview extends Component
{
public array $stats = [
'companies' => 12,
'warehouses' => 34,
'users' => 156,
];
public function render()
{
return stylist_view('livewire.theme-preview', [
'stats' => $this->stats,
]);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Stylist\Models;
use Illuminate\Database\Eloquent\Model;
final class ThemeSetting extends Model
{
protected $table = 'stylist_theme_settings';
protected $fillable = ['theme_key', 'variant', 'settings'];
protected $casts = ['settings' => 'array'];
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Stylist\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
parent::boot();
Route::middleware('web')
->group(module_path('Stylist', 'Routes/assets.php'));
}
}
@@ -0,0 +1,376 @@
<?php
namespace Modules\Stylist\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Modules\Stylist\Console\CreateThemeCommand;
use Modules\Stylist\Console\FindDuplicateViewsCommand;
use Modules\Stylist\Console\InspectThemesCommand;
use Modules\Stylist\Console\PublishAssetsCommand;
use Modules\Stylist\Console\ThemeDoctorCommand;
use Modules\Stylist\Console\ValidateThemeCommand;
use Modules\Stylist\Html\ThemeHtmlBuilder;
use Modules\Stylist\Html\ThemeSeoBuilder;
use Modules\Stylist\Support\ModuleInfo;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Stylist;
use Modules\Stylist\Theme\Theme;
final class StylistServiceProvider extends ServiceProvider
{
protected string $moduleName = 'Stylist';
protected string $moduleNameLower = 'stylist';
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
$this->app->singleton('stylist', fn ($app) => $app->make(Stylist::class));
$this->app->singleton('stylist.html', fn ($app) => $app->make(ThemeHtmlBuilder::class));
$this->app->singleton('stylist.seo', fn ($app) => $app->make(ThemeSeoBuilder::class));
}
public function boot(): void
{
$this->registerViews();
$this->registerThemeViews();
$this->registerTranslations();
$this->registerPublishing();
$this->registerHelpers();
$this->registerBlade();
if ($this->app->runningInConsole()) {
$this->commands([
PublishAssetsCommand::class,
ValidateThemeCommand::class,
InspectThemesCommand::class,
FindDuplicateViewsCommand::class,
ThemeDoctorCommand::class,
CreateThemeCommand::class,
]);
}
$this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
View::composer('*', function ($view): void {
if (! $this->app->bound(Stylist::class)) {
return;
}
$view->with('activeTheme', $this->app->make(Stylist::class)->current());
});
// Register both selected presentation trees immediately, then register them again
// after every service provider has finished booting. Some package/module providers
// replace Laravel's base view locations during their own boot phase; the booted
// callback makes Stylist the final authority for ordinary view('...') resolution.
$this->registerSelectedThemeOverrides();
$this->app->booted(function (): void {
$this->registerSelectedThemeOverrides();
});
}
private function registerViews(): void
{
$sourcePath = module_path(ModuleInfo::name(), 'Resources/views');
// Module-owned package views stay inside the module. Application overrides
// belong to resources/themes/{theme}/views/modules/stylist, which Stylist
// registers as the authoritative themed namespace. Never publish back into
// resources/views; the standard baseline intentionally has no legacy view tree.
$this->loadViewsFrom($sourcePath, ModuleInfo::nameLower());
}
private function registerThemeViews(): void
{
foreach ($this->themeRoots() as $root) {
if (! is_dir($root)) {
logger()->warning('Stylist theme root does not exist', [
'root' => $root,
]);
continue;
}
$themeDirectories = glob($root.'/*', GLOB_ONLYDIR) ?: [];
foreach ($themeDirectories as $themeDirectory) {
$themeViewsPath = $themeDirectory.'/views';
if (! is_dir($themeViewsPath)) {
continue;
}
$themeKey = strtolower(basename($themeDirectory));
// Themes always live outside the module, normally in resources/themes.
// Register the canonical namespace and the legacy namespace as an alias
// to the same application-owned directory. The alias keeps existing
// routes and Blade templates working without reintroducing module themes.
$namespaces = [
"stylist-theme-{$themeKey}",
"stylist-module-theme-{$themeKey}",
];
$componentsPath = $themeViewsPath.'/components';
// The common theme is the application-wide owner of layouts,
// authentication, settings and shared components. Livewire 4
// resolves its default full-page layout through layouts::app,
// so point that compatibility namespace at the common theme.
if ($themeKey === 'common') {
$layoutsPath = $componentsPath.'/layouts';
if (is_dir($layoutsPath)) {
View::addNamespace('layouts', $layoutsPath);
Blade::anonymousComponentPath($layoutsPath, 'layouts');
}
View::addNamespace('common', $themeViewsPath);
if (is_dir($componentsPath)) {
Blade::anonymousComponentPath($componentsPath, 'common');
}
}
foreach ($namespaces as $namespace) {
View::addNamespace($namespace, $themeViewsPath);
if (is_dir($componentsPath)) {
Blade::anonymousComponentPath($componentsPath, $namespace);
}
}
}
}
}
private function registerSelectedThemeOverrides(): void
{
$stylist = $this->app->make(Stylist::class);
$current = $stylist->current();
$frontend = $stylist->frontend();
$backend = $stylist->backend();
// The active request context must win ordinary view resolution. Keep the
// opposite presentation tree available as a compatibility fallback, but
// never let frontend views shadow backend views on backend requests (or
// vice versa) simply because of provider registration order.
$orderedThemes = [$current, $frontend, $backend];
$chains = [];
$seenThemes = [];
foreach ($orderedThemes as $theme) {
if (isset($seenThemes[$theme->key()])) {
continue;
}
$seenThemes[$theme->key()] = true;
$chains[] = $this->themeChain($theme);
}
$keys = [];
foreach ($chains as $chain) {
foreach ($chain as $key) {
if (! in_array($key, $keys, true)) {
$keys[] = $key;
}
}
}
if (! in_array('common', $keys, true)) {
$keys[] = 'common';
}
// Laravel's starter kits resolve authenticated layouts through the
// x-layouts:: namespace. Once those views live inside the backend
// theme, Stylist must make that selected theme authoritative for the
// namespace as well as for ordinary view('...') lookups.
$this->registerLayoutCompatibilityNamespace($stylist->backend());
$finder = View::getFinder();
$themePaths = [];
foreach ($keys as $key) {
try {
$theme = $this->app->make(Loader::class)->find($key);
} catch (\Throwable) {
continue;
}
$viewsPath = $theme->path().DIRECTORY_SEPARATOR.'views';
if (is_dir($viewsPath)) {
$themePaths[] = $viewsPath;
}
$this->registerThemeModuleNamespaces($key);
}
// FileViewFinder is the source of truth for ordinary view('...') resolution.
// Set the complete ordered path list explicitly instead of relying on provider
// boot order and incremental prependLocation() calls. Theme paths stay first;
// Laravel's normal view locations remain as the final fallback.
if (method_exists($finder, 'getPaths') && method_exists($finder, 'setPaths')) {
$existingPaths = $finder->getPaths();
$paths = array_values(array_unique(array_merge($themePaths, $existingPaths)));
$finder->setPaths($paths);
if (method_exists($finder, 'flush')) {
$finder->flush();
}
} else {
foreach (array_reverse($themePaths) as $viewsPath) {
if (method_exists($finder, 'prependLocation')) {
$finder->prependLocation($viewsPath);
} else {
View::addLocation($viewsPath);
}
}
}
}
/** @return array<int, string> */
private function themeChain(Theme $theme): array
{
$loader = $this->app->make(Loader::class);
$chain = [];
$seen = [];
while (! isset($seen[$theme->key()])) {
$seen[$theme->key()] = true;
$chain[] = $theme->key();
$parent = $theme->parent();
if (! $parent) {
break;
}
try {
$theme = $loader->find($parent);
} catch (\Throwable) {
break;
}
}
return $chain;
}
private function prependThemeViewTree(string $themeKey): void
{
try {
$theme = $this->app->make(Loader::class)->find($themeKey);
} catch (\Throwable) {
return;
}
$viewsPath = $theme->path().DIRECTORY_SEPARATOR.'views';
if (! is_dir($viewsPath)) {
return;
}
$finder = View::getFinder();
if (method_exists($finder, 'prependLocation')) {
$finder->prependLocation($viewsPath);
} else {
View::addLocation($viewsPath);
}
$this->registerThemeModuleNamespaces($themeKey);
}
private function registerLayoutCompatibilityNamespace(Theme $theme): void
{
$candidates = [
$theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'layouts',
$theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'components'.DIRECTORY_SEPARATOR.'layouts',
];
foreach ($candidates as $layoutsPath) {
if (! is_dir($layoutsPath)) {
continue;
}
View::prependNamespace('layouts', $layoutsPath);
Blade::anonymousComponentPath($layoutsPath, 'layouts');
}
}
private function registerThemeModuleNamespaces(string $themeKey): void
{
try {
$theme = $this->app->make(Loader::class)->find($themeKey);
} catch (\Throwable) {
return;
}
$modulesPath = $theme->path().DIRECTORY_SEPARATOR.'views'.DIRECTORY_SEPARATOR.'modules';
if (! is_dir($modulesPath)) {
return;
}
foreach (glob($modulesPath.'/*', GLOB_ONLYDIR) ?: [] as $moduleViewsPath) {
$namespace = strtolower(basename($moduleViewsPath));
View::prependNamespace($namespace, $moduleViewsPath);
$componentsPath = $moduleViewsPath.DIRECTORY_SEPARATOR.'components';
if (is_dir($componentsPath)) {
Blade::anonymousComponentPath($componentsPath, $namespace);
}
}
}
private function normalisePath(string $path): string
{
return rtrim(
str_replace('\\', '/', realpath($path) ?: $path),
'/'
);
}
/** @return array<int, string> */
private function themeRoots(): array
{
return array_values(array_unique(array_filter([
(string) config('stylist.themes_path'),
])));
}
private function registerTranslations(): void
{
$langPath = resource_path('lang/modules/'.ModuleInfo::nameLower());
$sourcePath = module_path(ModuleInfo::name(), 'Resources/lang');
$this->loadTranslationsFrom(is_dir($langPath) ? $langPath : $sourcePath, ModuleInfo::nameLower());
}
private function registerPublishing(): void
{
$this->publishes([
module_path(ModuleInfo::name(), 'Config/config.php') => config_path('stylist.php'),
], ['config', 'stylist-config']);
}
private function registerHelpers(): void
{
foreach (glob(module_path(ModuleInfo::name(), 'Helpers/*.php')) ?: [] as $filename) {
require_once $filename;
}
}
private function registerBlade(): void
{
Blade::directive('themeVariables', fn () => '<?php echo stylist_html()->variables(); ?>');
Blade::directive('themeStylesheet', fn ($expression) => '<?php echo stylist_html()->stylesheet('.($expression ?: 'null').'); ?>');
Blade::directive('themeScript', fn ($expression) => '<?php echo stylist_html()->script('.($expression ?: 'null').'); ?>');
Blade::directive('themeSwitcher', fn () => '<?php echo stylist_html()->switcher(); ?>');
Blade::directive('themeSeo', fn ($expression) => '<?php echo app(\Modules\Stylist\Html\ThemeSeoBuilder::class)->render('.($expression ?: '[]').'); ?>');
}
}
+183
View File
@@ -0,0 +1,183 @@
# Stylist Module
Tenant-aware styling module for Laravel 13 / nwidart Laravel Modules.
## Theme layout
Each theme is self-contained:
```text
resources/themes/{theme}/
├── theme.json
├── css/theme.css
└── views/
├── layouts/app.blade.php
├── components/page-hero.blade.php
└── livewire/theme-preview.blade.php
```
The active tenant/company resolves to a theme key such as `default`, `mpba`, or `abbott`.
## Rendering themed views
Use the helper from controllers, Blade components, and Livewire components:
```php
return stylist_view('layouts.app');
```
It checks views in this order:
```text
stylist-theme-{active-theme}::{view}
stylist-theme-{default-theme}::{view}
stylist::{view}
```
## Livewire
Keep the PHP component class shared and put the template in each theme:
```text
Livewire/ThemePreview.php
resources/themes/default/views/livewire/theme-preview.blade.php
resources/themes/mpba/views/livewire/theme-preview.blade.php
```
The component renders with:
```php
return stylist_view('livewire.theme-preview', ['stats' => $this->stats]);
```
## Tenant assignment
Add or use these fields on `companies`:
```text
theme_key
brand_primary
brand_secondary
brand_accent
logo_path
favicon_path
theme_settings
```
Then assign:
```php
$company->update(['theme_key' => 'mpba']);
```
## Publishing
```bash
php artisan vendor:publish --tag=stylist-config
# or
php artisan stylist:publish-assets
Themes are not published from or stored inside the module. Install them directly under `resources/themes`.
```
## Blade helpers
```blade
<html data-theme="{{ stylist()->current()->key() }}">
@themeVariables
@themeStylesheet
</html>
```
## Self-contained application themes
Application themes live in `resources/themes/{theme}` and own their runtime assets:
```text
resources/themes/example/
├── theme.json
├── css/theme.css
├── js/app.js
└── views/home.blade.php
```
Use `@themeStylesheet('css/theme.css')`, `@themeScript('js/app.js')`, or `theme_asset('images/example.svg')`. Assets are served from the selected theme directory by the `stylist.theme.asset` route; they do not need to be copied into `public/themes` or added to the global Vite inputs.
## Appearance and colour variants
Themes may declare `appearances` (`light`, `dark`, `auto`) and named `accents` in `theme.json`. Include `@themeVariables` in the document `<head>` before the theme stylesheet to prevent a flash of the wrong appearance. Add `@themeSwitcher` anywhere in a Blade layout for a ready-made runtime selector. User choices are stored in localStorage and are applied without rebuilding assets or reloading the page.
## Theme manifest compatibility
Stylist accepts versioned manifests while remaining compatible with older themes. New manifests should declare:
```json
{
"manifest_version": 1,
"key": "design-gallery",
"name": "Design Gallery"
}
```
`manifest_version` defaults to `1` when omitted. Unknown manifest fields are preserved and ignored by Stylist unless the current version explicitly uses them, allowing Theme Builder to add future metadata without preventing theme discovery.
Appearance and accent features are optional. Stylist derives the internal capability flags only when the corresponding definitions exist:
```json
{
"appearances": ["light", "dark", "system"],
"accents": ["blue", "neutral"],
"default_appearance": "system",
"default_accent": "neutral"
}
```
Legacy themes with none of these fields continue to load unchanged and do not receive appearance or accent controls.
Additional boolean capabilities may be declared explicitly:
```json
{
"capabilities": {
"marketplace": true
}
}
```
## Validate a theme
Validate an installed theme key, unpacked theme directory, or ZIP package:
```bash
php artisan stylist:validate design-gallery
php artisan stylist:validate resources/themes/design-gallery
php artisan stylist:validate ~/Downloads/design-gallery.zip
```
The command checks the manifest, required entry view, stylesheet, script, declared assets, preview and accessibility issues. It exits with a failure status when errors are found.
## Theme inheritance and diagnostics
Shared application views belong to the `common` theme. Child themes declare inheritance without copying those views:
```json
{
"name": "Dashboard",
"slug": "admin",
"extends": "common"
}
```
Resolution order is the active theme, each declared parent, the configured fallback, the common theme, module defaults, then Laravel's normal views.
Use these maintenance commands:
```bash
php artisan stylist:inspect
php artisan stylist:duplicates
php artisan stylist:doctor
php artisan stylist:create-theme customer --parent=common --name="Customer Portal"
```
`stylist:duplicates` fails when the same relative Blade task path exists in more than one theme. Add a child view only when it is a deliberate replacement; this application enforces one authoritative view per task.
@@ -0,0 +1,5 @@
[data-theme] {
--brand-primary: var(--brand-primary, #2563eb);
--brand-secondary: var(--brand-secondary, #0f172a);
--brand-accent: var(--brand-accent, #22c55e);
}
@@ -0,0 +1 @@
import '../css/app.css';
@@ -0,0 +1,5 @@
<?php
return [
'name' => 'Stylist',
];
+16
View File
@@ -0,0 +1,16 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Stylist\Http\Controllers\ThemeAssetController;
/*
| Public theme assets
| --------------------------------------------------------------------------
| Themes are stored beneath resources/themes, which is not web-accessible.
| This route safely streams allow-listed assets through Laravel at the URL
| expected by generated themes: /themes/{theme}/{path}.
*/
Route::get('/themes/{theme}/{path}', ThemeAssetController::class)
->where('theme', '[A-Za-z0-9_-]+')
->where('path', '.*')
->name('stylist.theme.asset');
+17
View File
@@ -0,0 +1,17 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Stylist\Http\Controllers\StylistController;
use Modules\Stylist\Http\Controllers\ThemeManagementController;
Route::middleware(['auth', 'verified'])->group(function (): void {
Route::get('/', StylistController::class)->name('index');
Route::post('/install', [ThemeManagementController::class, 'install'])->name('install');
Route::put('/{theme}/settings', [ThemeManagementController::class, 'settings'])->name('settings');
Route::post('/{theme}/activate', [ThemeManagementController::class, 'activate'])->name('activate');
Route::get('/{theme}/preview', [ThemeManagementController::class, 'preview'])->name('preview');
Route::delete('/{theme}', [ThemeManagementController::class, 'uninstall'])->name('uninstall');
Route::get('/{theme}/export', [ThemeManagementController::class, 'export'])->name('export');
Route::post('/{theme}/optimize', [ThemeManagementController::class, 'optimize'])->name('optimize');
Route::get('/{theme}/accessibility', [ThemeManagementController::class, 'validateTheme'])->name('accessibility');
});
@@ -0,0 +1,102 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Modules\Blog\Models\Blog;
use Modules\Faq\Models\Faq;
use Modules\News\Models\News;
use Modules\Products\Models\Product;
use Modules\Team\Models\TeamMember;
use Modules\Testimonials\Models\Testimonial;
use Throwable;
final class FrontendContent
{
/** @return array<string, mixed> */
public function homepage(array $settings): array
{
// Always provide source collections. Visibility is owned by page placements
// in the composable content system; the legacy theme still applies its
// existing show_* settings while no composition has been installed.
return [
'pricingPlans' => $this->pricingPlans(),
'testimonials' => $this->testimonials(),
'faqs' => $this->faqs(),
'teamMembers' => $this->team(),
'latestBlogPosts' => $this->blogPosts(),
'latestNewsArticles' => $this->newsArticles(),
];
}
private function pricingPlans(): Collection
{
return $this->query('products', fn () => Product::query()
->where('is_active', true)
->where('product_type', 'plan')
->orderBy('sort_order')
->orderBy('id')
->limit(6)
->get());
}
private function testimonials(): Collection
{
return $this->query('testimonials', fn () => Testimonial::query()
->where('enabled', true)
->latest('id')
->limit(6)
->get());
}
private function faqs(): Collection
{
return $this->query('faqs', fn () => Faq::query()
->where('enabled', true)
->orderBy('sort_order')
->limit(12)
->get());
}
private function team(): Collection
{
return $this->query('team_members', fn () => TeamMember::query()
->where('enabled', true)
->orderBy('sort_order')
->limit(8)
->get());
}
private function blogPosts(): Collection
{
return $this->query('blog_posts', fn () => Blog::query()
->published()
->latest('published_at')
->limit(3)
->get());
}
private function newsArticles(): Collection
{
return $this->query('news_articles', fn () => News::query()
->published()
->latest('published_at')
->limit(3)
->get());
}
private function query(string $table, callable $callback): Collection
{
try {
return Schema::hasTable($table) ? $callback() : collect();
} catch (Throwable) {
return collect();
}
}
private function enabled(array $settings, string $key, bool $default): bool
{
return filter_var($settings[$key] ?? $default, FILTER_VALIDATE_BOOL);
}
}
@@ -0,0 +1,96 @@
<?php
namespace Modules\Stylist\Services;
use App\Models\SiteSetting;
use Illuminate\Support\Facades\Auth;
final class TenantThemeResolver
{
public function resolve(?string $context = null): string
{
$context ??= $this->context();
$column = config('stylist.tenant_theme_column', 'theme_key');
$user = Auth::user();
$company = $user?->company ?? null;
/*
* The legacy company theme_key is a public-facing tenant theme.
* It must never replace the application's backend/admin theme.
*/
if ($context === 'frontend' && $company && filled($company->{$column} ?? null)) {
return (string) $company->{$column};
}
$settingKey = $context === 'frontend' ? 'frontend_theme' : 'backend_theme';
$default = $context === 'frontend'
? config('stylist.default_frontend_theme', 'frontend')
: config('stylist.default_backend_theme', config('stylist.default_theme', 'backend'));
try {
return (string) SiteSetting::valueOf($settingKey, $default);
} catch (\Throwable) {
return (string) $default;
}
}
public function context(): string
{
if (app()->runningInConsole()) {
return 'backend';
}
$request = request();
return $request->is(
'dashboard',
'dashboard/*',
'settings',
'settings/*',
'admin',
'admin/*',
'helpdesk',
'helpdesk/*',
'login',
'register',
'forgot-password',
'reset-password',
'reset-password/*',
'confirm-password',
'user/confirm-password',
'two-factor-challenge',
'verify-email',
'verify-email/*',
'email/verify',
'email/verify/*',
) || $request->routeIs(
'dashboard',
'profile.edit',
'appearance.edit',
'security.edit',
'admin.*',
'helpdesk.*',
'login',
'register',
'password.*',
'verification.*',
'two-factor.*',
)
? 'backend'
: 'frontend';
}
public function variables(): array
{
$variables = config('stylist.fallback_variables', []);
$company = Auth::user()?->company ?? null;
foreach (config('stylist.tenant_brand_columns', []) as $column => $variable) {
if ($company && filled($company->{$column} ?? null)) {
$variables[$variable] = (string) $company->{$column};
}
}
return $variables;
}
}
@@ -0,0 +1,72 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Theme;
final class ThemeAccessibilityValidator
{
public function __construct(private readonly Filesystem $files) {}
/** @return array<int, array{severity:string,message:string,file:string}> */
public function validate(Theme $theme): array
{
$issues = [];
foreach ($this->files->allFiles($theme->path().'/views') as $file) {
$content = $file->getContents();
$relative = $file->getRelativePathname();
$missingAlt = 0;
if (preg_match_all('/<img\\b(?:(?:"[^"]*")|(?:\'[^\']*\')|[^\'">])*>/is', $content, $images)) {
foreach ($images[0] as $image) {
if (! preg_match('/\\balt\\s*=\\s*(?:"[^"]*"|\'[^\']*\')/is', $image)) {
$missingAlt++;
}
}
}
if ($missingAlt > 0) {
$issues[] = [
'severity' => 'error',
'message' => $missingAlt.' image(s) missing alt text.',
'file' => $relative,
];
}
if (preg_match('/<html\\b(?![^>]*\\blang=)/i', $content)) {
$issues[] = [
'severity' => 'warning',
'message' => 'HTML element is missing a lang attribute.',
'file' => $relative,
];
}
$unnamedButtons = 0;
if (preg_match_all('/<button\\b((?:(?:"[^"]*")|(?:\'[^\']*\')|[^\'">])*)>(.*?)<\\/button>/is', $content, $buttons, PREG_SET_ORDER)) {
foreach ($buttons as $button) {
$attributes = $button[1];
$body = $button[2];
$hasAccessibleAttribute = preg_match('/\\b(?:aria-label|aria-labelledby|title)\\s*=\\s*(?:"[^"]+"|\'[^\']+\')/is', $attributes) === 1;
$visibleText = trim(html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_HTML5));
if (! $hasAccessibleAttribute && $visibleText === '') {
$unnamedButtons++;
}
}
}
if ($unnamedButtons > 0) {
$issues[] = [
'severity' => 'warning',
'message' => $unnamedButtons.' button(s) may lack an accessible name.',
'file' => $relative,
];
}
}
return $issues;
}
}
@@ -0,0 +1,57 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Theme;
final class ThemeOptimizer
{
public function __construct(private readonly Filesystem $files) {}
public function optimize(Theme $theme): array
{
$result = ['css' => 0, 'js' => 0, 'images' => 0];
foreach (['css' => 'css', 'js' => 'js'] as $type => $folder) {
$directory = $theme->path().'/'.$folder;
if (! $this->files->isDirectory($directory)) {
continue;
}
foreach ($this->files->files($directory) as $file) {
if ($file->getExtension() !== $type || str_ends_with($file->getFilename(), '.min.'.$type)) {
continue;
}
$content = $file->getContents();
$content = preg_replace('!/\*.*?\*/!s', '', $content) ?? $content;
$content = preg_replace('/\s+/', ' ', $content) ?? $content;
$content = preg_replace('/\s*([{}:;,>])\s*/', '$1', $content) ?? $content;
$target = $file->getPath().'/'.$file->getBasename('.'.$type).'.min.'.$type;
$this->files->put($target, trim($content));
$result[$type]++;
}
}
if (function_exists('imagewebp')) {
foreach (['images', 'icons'] as $folder) {
$directory = $theme->path().'/'.$folder;
if (! $this->files->isDirectory($directory)) {
continue;
}
foreach ($this->files->files($directory) as $file) {
$ext = strtolower($file->getExtension());
if (! in_array($ext, ['jpg', 'jpeg', 'png'], true)) {
continue;
}
$image = $ext === 'png' ? @imagecreatefrompng($file->getPathname()) : @imagecreatefromjpeg($file->getPathname());
if ($image) {
imagepalettetotruecolor($image);
imagewebp($image, $file->getPath().'/'.$file->getBasename('.'.$ext).'.webp', 82);
imagedestroy($image);
$result['images']++;
}
}
}
}
return $result;
}
}
@@ -0,0 +1,98 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Http\UploadedFile;
use Modules\Settings\Services\SettingsRepository;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Theme;
use RuntimeException;
use ZipArchive;
final class ThemePackageManager
{
public function __construct(private readonly Filesystem $files, private readonly Loader $loader, private readonly ThemeValidator $validator, private readonly SettingsRepository $settings) {}
public function install(UploadedFile $upload): Theme
{
if (strtolower($upload->getClientOriginalExtension()) !== 'zip') {
throw new RuntimeException('Only ZIP theme packages may be installed.');
}
$temporary = storage_path('app/stylist/install-'.bin2hex(random_bytes(8)));
$this->files->ensureDirectoryExists($temporary);
$zip = new ZipArchive;
if ($zip->open($upload->getRealPath()) !== true) {
throw new RuntimeException('The uploaded ZIP could not be opened.');
}
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (str_contains($name, '..') || str_starts_with($name, '/') || str_contains($name, "\0")) {
$zip->close();
throw new RuntimeException('The ZIP contains an unsafe path.');
}
}
$zip->extractTo($temporary);
$zip->close();
$manifest = collect($this->files->allFiles($temporary))->first(fn ($file) => $file->getFilename() === 'theme.json');
if (! $manifest) {
$this->files->deleteDirectory($temporary);
throw new RuntimeException('The package does not contain theme.json.');
}
$source = $manifest->getPath();
$theme = $this->loader->fromPath($source);
if ($theme->parent() && ! $this->loader->all()->has($theme->parent())) {
throw new RuntimeException('Parent theme '.$theme->parent().' is not installed.');
}
$issues = $this->validator->validate($theme);
if (collect($issues)->contains('severity', 'error')) {
throw new RuntimeException('Accessibility validation failed: '.collect($issues)->where('severity', 'error')->pluck('message')->implode(' '));
}
$target = rtrim((string) config('stylist.themes_path'), '/').'/'.$theme->key();
if ($this->files->isDirectory($target)) {
$this->files->deleteDirectory($target);
}
$this->files->copyDirectory($source, $target);
$this->files->deleteDirectory($temporary);
$this->loader->flush();
return $this->loader->find($theme->key());
}
public function uninstall(Theme $theme): void
{
$active = $theme->type() === 'frontend'
? $this->settings->get('frontend_theme', config('stylist.default_frontend_theme'), 'site')
: $this->settings->get('backend_theme', config('stylist.default_backend_theme'), 'site');
if ($active === $theme->key()) {
throw new RuntimeException('The active theme cannot be uninstalled. Activate another theme first.');
}
$projectRoot = realpath((string) config('stylist.themes_path'));
$themePath = realpath($theme->path());
if (! $projectRoot || ! $themePath || ! str_starts_with($themePath, $projectRoot.DIRECTORY_SEPARATOR)) {
throw new RuntimeException('Bundled themes cannot be uninstalled.');
}
if (collect($this->loader->all())->contains(fn (Theme $candidate) => $candidate->parent() === $theme->key())) {
throw new RuntimeException('This theme has installed child themes.');
}
$this->files->deleteDirectory($themePath);
$this->loader->flush();
}
public function export(Theme $theme): string
{
$directory = storage_path('app/stylist/exports');
$this->files->ensureDirectoryExists($directory);
$path = $directory.'/'.$theme->key().'-'.$theme->version().'.zip';
$zip = new ZipArchive;
if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('Unable to create theme archive.');
}
foreach ($this->files->allFiles($theme->path()) as $file) {
$zip->addFile($file->getPathname(), $theme->key().'/'.$file->getRelativePathname());
}
$zip->close();
return $path;
}
}
@@ -0,0 +1,97 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Database\QueryException;
use Modules\Stylist\Models\ThemeSetting;
use Modules\Stylist\Theme\Theme;
final class ThemeSettings
{
private const APPEARANCE_KEY = '__stylist_appearance';
private const ACCENT_KEY = '__stylist_accent';
public function get(Theme $theme): array
{
$stored = $this->stored($theme);
$defaults = collect($theme->settingsSchema())
->mapWithKeys(fn ($definition, $key) => [$key => is_array($definition) ? ($definition['default'] ?? null) : $definition])
->all();
return array_replace($defaults, array_diff_key((array) ($stored?->settings ?? []), array_flip([self::APPEARANCE_KEY, self::ACCENT_KEY])));
}
public function variant(Theme $theme): string
{
$variant = (string) ($this->stored($theme)?->variant ?? array_key_first($theme->variants()) ?? 'light');
return array_key_exists($variant, $theme->variants()) ? $variant : (array_key_first($theme->variants()) ?? 'light');
}
public function appearance(Theme $theme): string
{
$stored = (array) ($this->stored($theme)?->settings ?? []);
$allowed = array_keys($theme->appearances());
if ($allowed === []) {
return '';
}
$appearance = (string) ($stored[self::APPEARANCE_KEY] ?? $theme->defaultAppearance());
return in_array($appearance, $allowed, true) ? $appearance : (string) array_key_first($theme->appearances());
}
public function accent(Theme $theme): string
{
$stored = (array) ($this->stored($theme)?->settings ?? []);
$allowed = array_keys($theme->accents());
if ($allowed === []) {
return '';
}
$accent = (string) ($stored[self::ACCENT_KEY] ?? $theme->defaultAccent());
return in_array($accent, $allowed, true) ? $accent : (string) array_key_first($theme->accents());
}
public function save(Theme $theme, array $settings, string $variant, ?string $appearance = null, ?string $accent = null): ThemeSetting
{
$settings = array_intersect_key($settings, array_flip(array_keys($theme->settingsSchema())));
$existing = (array) ($this->stored($theme)?->settings ?? []);
$allowedAppearances = array_keys($theme->appearances());
if ($allowedAppearances !== []) {
$appearance ??= (string) ($existing[self::APPEARANCE_KEY] ?? $theme->defaultAppearance());
if (! in_array($appearance, $allowedAppearances, true)) {
$appearance = (string) array_key_first($theme->appearances());
}
$settings[self::APPEARANCE_KEY] = $appearance;
}
$allowedAccents = array_keys($theme->accents());
if ($allowedAccents !== []) {
$accent ??= (string) ($existing[self::ACCENT_KEY] ?? $theme->defaultAccent());
if (! in_array($accent, $allowedAccents, true)) {
$accent = (string) array_key_first($theme->accents());
}
$settings[self::ACCENT_KEY] = $accent;
}
if ($theme->variants() && ! array_key_exists($variant, $theme->variants())) {
$variant = array_key_first($theme->variants());
}
return ThemeSetting::query()->updateOrCreate(
['theme_key' => $theme->key()],
['settings' => $settings, 'variant' => $variant]
);
}
private function stored(Theme $theme): ?ThemeSetting
{
try {
return ThemeSetting::query()->where('theme_key', $theme->key())->first();
} catch (QueryException) {
return null;
}
}
}
@@ -0,0 +1,74 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Theme;
final class ThemeValidator
{
public function __construct(
private readonly Filesystem $files,
private readonly Loader $loader,
private readonly ThemeAccessibilityValidator $accessibility,
) {}
/** @return array<int, array{severity:string,message:string,file:string}> */
public function validatePath(string $path): array
{
$theme = $this->loader->fromPath($path);
return $this->validate($theme);
}
/** @return array<int, array{severity:string,message:string,file:string}> */
public function validate(Theme $theme): array
{
$issues = [];
$entryView = 'views/'.str_replace('.', '/', $theme->entryView()).'.blade.php';
if (! $theme->has($entryView)) {
$issues[] = [
'severity' => 'error',
'message' => "Entry view [{$theme->entryView()}] was not found.",
'file' => $entryView,
];
}
foreach (['stylesheet' => $theme->stylesheet(), 'script' => $theme->script()] as $label => $relativePath) {
if ($relativePath !== '' && ! $theme->has($relativePath)) {
$issues[] = [
'severity' => 'error',
'message' => ucfirst($label)." file [{$relativePath}] was not found.",
'file' => $relativePath,
];
}
}
$preview = $theme->preview();
if ($preview !== null && ! $theme->has($preview)) {
$issues[] = [
'severity' => 'warning',
'message' => "Preview file [{$preview}] was not found.",
'file' => $preview,
];
}
foreach ((array) ($theme->meta()['assets'] ?? []) as $asset) {
if (! is_string($asset) || $asset === '') {
continue;
}
if (! $theme->has($asset)) {
$issues[] = [
'severity' => 'warning',
'message' => "Declared asset [{$asset}] was not found.",
'file' => $asset,
];
}
}
return array_merge($issues, $this->accessibility->validate($theme));
}
}
@@ -0,0 +1,83 @@
<?php
namespace Modules\Stylist\Support;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Modules\Menus\Models\Menu;
use Throwable;
final class FrontendNavigation
{
/** @return array<int, array<string, mixed>> */
public function primary(): array
{
return $this->location('primary', $this->fallbackPrimary());
}
/** @return array<int, array<string, mixed>> */
public function footer(): array
{
return $this->location('footer', $this->fallbackPrimary());
}
/** @param array<int, array<string, mixed>> $fallback
* @return array<int, array<string, mixed>>
*/
public function location(string $location, array $fallback = []): array
{
try {
if (! Schema::hasTable('menus')) {
return $fallback;
}
$menu = Menu::query()->where('enabled', true)->where('location', $location)->first();
if (! $menu || ! is_array($menu->items)) {
return $fallback;
}
$items = $this->normaliseItems($menu->items);
return $items === [] ? $fallback : $items;
} catch (Throwable) {
return $fallback;
}
}
/** @param array<int, mixed> $items
* @return array<int, array<string, mixed>>
*/
private function normaliseItems(array $items): array
{
return collect($items)
->filter(fn (mixed $item): bool => is_array($item) && filter_var($item['enabled'] ?? true, FILTER_VALIDATE_BOOL))
->map(function (array $item): ?array {
$label = trim((string) ($item['label'] ?? ''));
$url = trim((string) ($item['url'] ?? ''));
if ($label === '' || $url === '') {
return null;
}
return [
'label' => $label,
'url' => $url,
'target' => ($item['target'] ?? '') === '_blank' ? '_blank' : null,
'children' => $this->normaliseItems((array) ($item['children'] ?? [])),
];
})->filter()->values()->all();
}
/** @return array<int, array<string, mixed>> */
private function fallbackPrimary(): array
{
$items = [['label' => 'Home', 'url' => url('/'), 'target' => null, 'children' => []]];
if (Route::has('blog.public.index')) {
$items[] = ['label' => 'Blog', 'url' => route('blog.public.index'), 'target' => null, 'children' => []];
}
if (Route::has('news.public.index')) {
$items[] = ['label' => 'News', 'url' => route('news.public.index'), 'target' => null, 'children' => []];
}
return $items;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Modules\Stylist\Support;
final class ModuleInfo
{
public static function name(): string
{
return 'Stylist';
}
public static function nameLower(): string
{
return 'stylist';
}
}
@@ -0,0 +1,14 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Tests\TestCase;
final class LegacyThemeNamespaceAliasTest extends TestCase
{
public function test_legacy_theme_namespace_resolves_to_application_theme_directory(): void
{
$this->assertTrue(view()->exists('stylist-module-theme-backend::dashboard'));
$this->assertTrue(view()->exists('stylist-theme-backend::dashboard'));
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Modules\Stylist\Theme\Stylist;
use Tests\TestCase;
final class StylistModuleTest extends TestCase
{
public function test_stylist_service_resolves(): void
{
$this->assertInstanceOf(Stylist::class, app(Stylist::class));
}
public function test_default_theme_resolves(): void
{
config(['stylist.default_theme' => 'backend']);
$this->assertSame('backend', app(Stylist::class)->current()->key());
}
}
@@ -0,0 +1,52 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Illuminate\Http\Request;
use Modules\Stylist\Services\TenantThemeResolver;
use Tests\TestCase;
final class TenantThemeResolverTest extends TestCase
{
public function test_dashboard_request_uses_backend_context(): void
{
$request = Request::create('/dashboard', 'GET');
$this->app->instance('request', $request);
$this->assertSame('backend', app(TenantThemeResolver::class)->context());
}
public function test_settings_request_uses_backend_context(): void
{
$request = Request::create('/settings/profile', 'GET');
$this->app->instance('request', $request);
$this->assertSame('backend', app(TenantThemeResolver::class)->context());
}
public function test_admin_request_uses_backend_context(): void
{
$request = Request::create('/admin', 'GET');
$this->app->instance('request', $request);
$this->assertSame('backend', app(TenantThemeResolver::class)->context());
}
public function test_admin_helpdesk_request_uses_backend_context(): void
{
$request = Request::create('/admin/helpdesk', 'GET');
$this->app->instance('request', $request);
$this->assertSame('backend', app(TenantThemeResolver::class)->context());
}
public function test_authentication_requests_use_backend_context(): void
{
foreach (['/login', '/register', '/forgot-password', '/reset-password/token', '/user/confirm-password', '/two-factor-challenge', '/email/verify'] as $uri) {
$request = Request::create($uri, 'GET');
$this->app->instance('request', $request);
$this->assertSame('backend', app(TenantThemeResolver::class)->context(), $uri);
}
}
}
@@ -0,0 +1,93 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Services\ThemeAccessibilityValidator;
use Modules\Stylist\Theme\Theme;
use Tests\TestCase;
final class ThemeAccessibilityValidatorTest extends TestCase
{
private string $themePath;
protected function setUp(): void
{
parent::setUp();
$this->themePath = storage_path('framework/testing/stylist-accessibility-'.uniqid());
mkdir($this->themePath.'/views', 0777, true);
}
protected function tearDown(): void
{
app(Filesystem::class)->deleteDirectory($this->themePath);
parent::tearDown();
}
public function test_blade_object_access_does_not_break_image_alt_detection(): void
{
file_put_contents(
$this->themePath.'/views/page.blade.php',
'<img src="{{ $post->featuredImageUrl() }}" alt="{{ $post->title }}">'
);
$this->assertSame([], $this->validate());
}
public function test_image_without_alt_is_reported(): void
{
file_put_contents(
$this->themePath.'/views/page.blade.php',
'<img src="{{ $post->featuredImageUrl() }}">'
);
$issues = $this->validate();
$this->assertCount(1, $issues);
$this->assertSame('error', $issues[0]['severity']);
$this->assertSame('1 image(s) missing alt text.', $issues[0]['message']);
}
public function test_aria_labelled_icon_button_is_not_reported(): void
{
file_put_contents(
$this->themePath.'/views/page.blade.php',
'<button type="button" aria-label="Open navigation"><span></span><span></span></button>'
);
$this->assertSame([], $this->validate());
}
public function test_button_with_visible_text_is_not_reported(): void
{
file_put_contents(
$this->themePath.'/views/page.blade.php',
'<button type="submit"><span>Save</span></button>'
);
$this->assertSame([], $this->validate());
}
public function test_empty_unlabelled_button_is_reported(): void
{
file_put_contents(
$this->themePath.'/views/page.blade.php',
'<button type="button"><span></span></button>'
);
$issues = $this->validate();
$this->assertCount(1, $issues);
$this->assertSame('warning', $issues[0]['severity']);
$this->assertSame('1 button(s) may lack an accessible name.', $issues[0]['message']);
}
/** @return array<int, array{severity:string,message:string,file:string}> */
private function validate(): array
{
$theme = new Theme('test', 'Test', $this->themePath);
return app(ThemeAccessibilityValidator::class)->validate($theme);
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Modules\Stylist\Theme\Loader;
use Tests\TestCase;
final class ThemeAssetControllerTest extends TestCase
{
public function test_active_theme_css_is_served(): void
{
config(['stylist.themes_path' => resource_path('themes')]);
app(Loader::class)->flush();
$this->get(route('stylist.theme.asset', [
'theme' => 'frontend',
'path' => 'css/theme.css',
]))->assertOk()->assertHeader('X-Content-Type-Options', 'nosniff');
}
public function test_asset_url_uses_public_themes_path(): void
{
$url = route('stylist.theme.asset', [
'theme' => 'frontend',
'path' => 'images/example.jpg',
], false);
$this->assertSame('/themes/frontend/images/example.jpg', $url);
}
public function test_non_asset_theme_files_are_not_served(): void
{
config(['stylist.themes_path' => resource_path('themes')]);
app(Loader::class)->flush();
$this->get(route('stylist.theme.asset', [
'theme' => 'frontend',
'path' => 'views/home.blade.php',
]))->assertNotFound();
}
}
@@ -0,0 +1,38 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Modules\Stylist\Html\ThemeHtmlBuilder;
use Tests\TestCase;
final class ThemeHtmlBuilderTest extends TestCase
{
public function test_css_variables_are_rendered(): void
{
config(['stylist.fallback_variables.brand-primary' => '#111827']);
$html = app(ThemeHtmlBuilder::class)->variables()->toHtml();
$this->assertStringContainsString('--brand-primary: #111827;', $html);
}
public function test_appearance_does_not_overwrite_theme_identity(): void
{
$html = app(ThemeHtmlBuilder::class)->variables()->toHtml();
$this->assertStringContainsString('d.dataset.theme=k', $html);
$this->assertStringContainsString('d.dataset.appearance=dark?', $html);
$this->assertStringNotContainsString("d.dataset.theme=dark?'dark':'light'", $html);
}
public function test_appearance_variables_use_data_appearance_selector(): void
{
$html = app(ThemeHtmlBuilder::class)->variables()->toHtml();
if (str_contains($html, 'data-appearance')) {
$this->assertStringNotContainsString(':root[data-theme="dark"]', $html);
} else {
$this->addToAssertionCount(1);
}
}
}
@@ -0,0 +1,95 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Diagnostics\ThemeInspector;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\View\ThemeViewResolver;
use Tests\TestCase;
final class ThemeInheritanceTest extends TestCase
{
private string $themesPath;
protected function setUp(): void
{
parent::setUp();
$this->themesPath = storage_path('framework/testing/stylist-inheritance-'.uniqid());
mkdir($this->themesPath, 0777, true);
config([
'stylist.themes_path' => $this->themesPath,
'stylist.default_theme' => 'common',
]);
app(Loader::class)->flush();
}
protected function tearDown(): void
{
app(Filesystem::class)->deleteDirectory($this->themesPath);
parent::tearDown();
}
public function test_child_theme_resolves_views_through_parent_without_copying_them(): void
{
$this->theme('common', null, ['components/layouts/app.blade.php' => '<main>{{ $slot }}</main>']);
$this->theme('admin', 'common', ['dashboard.blade.php' => '<h1>Dashboard</h1>']);
app(Loader::class)->flush();
$candidates = app(ThemeViewResolver::class)->candidatesForTheme('admin', 'components.layouts.app');
$this->assertSame('stylist-theme-admin::components.layouts.app', $candidates[0]);
$this->assertContains('stylist-theme-common::components.layouts.app', $candidates);
$this->assertLessThan(
array_search('stylist-theme-common::components.layouts.app', $candidates, true),
array_search('stylist-theme-admin::components.layouts.app', $candidates, true),
);
}
public function test_parent_alias_is_normalised_to_extends(): void
{
$path = $this->themesPath.'/child';
mkdir($path, 0777, true);
file_put_contents($path.'/theme.json', json_encode([
'name' => 'Child',
'parent' => 'common',
]));
$theme = app(Loader::class)->fromPath($path);
$this->assertSame('common', $theme->parent());
}
public function test_duplicate_view_ownership_is_reported(): void
{
$this->theme('common', null, ['livewire/auth/login.blade.php' => 'common']);
$this->theme('admin', 'common', ['livewire/auth/login.blade.php' => 'admin']);
app(Loader::class)->flush();
$duplicates = app(ThemeInspector::class)->duplicateViews();
$this->assertArrayHasKey('livewire/auth/login.blade.php', $duplicates);
$this->assertEqualsCanonicalizing(['common', 'admin'], array_column($duplicates['livewire/auth/login.blade.php'], 'theme'));
}
/** @param array<string, string> $views */
private function theme(string $key, ?string $parent, array $views): void
{
$path = $this->themesPath.'/'.$key;
mkdir($path.'/views', 0777, true);
file_put_contents($path.'/theme.json', json_encode(array_filter([
'name' => ucfirst($key),
'slug' => $key,
'extends' => $parent,
], fn ($value) => $value !== null)));
foreach ($views as $relative => $contents) {
$file = $path.'/views/'.$relative;
if (! is_dir(dirname($file))) {
mkdir(dirname($file), 0777, true);
}
file_put_contents($file, $contents);
}
}
}
@@ -0,0 +1,166 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Exceptions\InvalidThemeManifestException;
use Modules\Stylist\Theme\Loader;
use Tests\TestCase;
final class ThemeManifestTest extends TestCase
{
private string $themesPath;
protected function setUp(): void
{
parent::setUp();
$this->themesPath = storage_path('framework/testing/stylist-themes-'.uniqid());
mkdir($this->themesPath, 0777, true);
config(['stylist.themes_path' => $this->themesPath]);
app(Loader::class)->flush();
}
protected function tearDown(): void
{
app(Filesystem::class)->deleteDirectory($this->themesPath);
parent::tearDown();
}
public function test_bundled_default_themes_have_valid_manifests(): void
{
foreach (['backend', 'frontend'] as $key) {
$theme = app(Loader::class)->fromPath(resource_path("themes/{$key}"));
$this->assertSame($key, $theme->key());
}
}
public function test_manifest_is_normalised(): void
{
$path = $this->themesPath.'/sample';
mkdir($path.'/css', 0777, true);
file_put_contents($path.'/theme.json', json_encode(['name' => 'Sample']));
file_put_contents($path.'/css/theme.css', 'body{}');
$theme = app(Loader::class)->fromPath($path);
$this->assertSame('sample', $theme->key());
$this->assertSame('css/theme.css', $theme->stylesheet());
$this->assertSame('js/app.js', $theme->script());
$this->assertSame('home', $theme->entryView());
}
public function test_unsafe_manifest_path_is_rejected(): void
{
$path = $this->themesPath.'/unsafe';
mkdir($path, 0777, true);
file_put_contents($path.'/theme.json', json_encode([
'name' => 'Unsafe',
'stylesheet' => '../secret.css',
]));
$this->expectException(InvalidThemeManifestException::class);
app(Loader::class)->fromPath($path);
}
public function test_phase_one_manifest_optional_capabilities_are_normalised(): void
{
$path = $this->themesPath.'/design-gallery';
mkdir($path.'/css', 0777, true);
mkdir($path.'/js', 0777, true);
file_put_contents($path.'/theme.json', json_encode([
'key' => 'design-gallery',
'name' => 'Design Gallery',
'stylesheet' => 'css/app.css',
'script' => 'js/app.js',
'appearances' => ['light', 'dark', 'system'],
'accents' => ['blue', 'emerald', 'neutral'],
'default_appearance' => 'system',
'default_accent' => 'neutral',
// Phase 1 incorrectly duplicated accents here; Stylist must not
// reject the whole theme because of that compact legacy list.
'variants' => ['blue', 'emerald', 'neutral'],
]));
file_put_contents($path.'/css/app.css', 'body{}');
file_put_contents($path.'/js/app.js', '');
$theme = app(Loader::class)->fromPath($path);
$this->assertSame(['light', 'dark', 'system'], array_keys($theme->appearances()));
$this->assertSame(['blue', 'emerald', 'neutral'], array_keys($theme->accents()));
$this->assertSame([], $theme->variants());
$this->assertSame('system', $theme->defaultAppearance());
$this->assertSame('neutral', $theme->defaultAccent());
}
public function test_legacy_manifest_does_not_gain_appearance_or_accent_capabilities(): void
{
$path = $this->themesPath.'/legacy';
mkdir($path.'/css', 0777, true);
file_put_contents($path.'/theme.json', json_encode(['name' => 'Legacy']));
file_put_contents($path.'/css/theme.css', 'body{}');
$theme = app(Loader::class)->fromPath($path);
$this->assertSame([], $theme->appearances());
$this->assertSame([], $theme->accents());
$this->assertSame('', $theme->defaultAppearance());
$this->assertSame('', $theme->defaultAccent());
}
public function test_defaults_must_reference_declared_optional_capabilities(): void
{
$path = $this->themesPath.'/invalid-default';
mkdir($path, 0777, true);
file_put_contents($path.'/theme.json', json_encode([
'name' => 'Invalid Default',
'appearances' => ['light', 'dark'],
'default_appearance' => 'system',
]));
$this->expectException(InvalidThemeManifestException::class);
app(Loader::class)->fromPath($path);
}
public function test_manifest_version_and_capabilities_are_normalised(): void
{
$path = $this->themesPath.'/capable';
mkdir($path.'/css', 0777, true);
mkdir($path.'/js', 0777, true);
mkdir($path.'/views', 0777, true);
file_put_contents($path.'/theme.json', json_encode([
'manifest_version' => 1,
'name' => 'Capable',
'appearances' => ['light', 'dark'],
'accents' => ['blue'],
'capabilities' => ['marketplace' => true],
]));
file_put_contents($path.'/css/theme.css', 'body{}');
file_put_contents($path.'/js/app.js', '');
file_put_contents($path.'/views/home.blade.php', '<div>Capable</div>');
$theme = app(Loader::class)->fromPath($path);
$this->assertSame(1, $theme->manifestVersion());
$this->assertTrue($theme->hasCapability('appearances'));
$this->assertTrue($theme->hasCapability('accents'));
$this->assertTrue($theme->hasCapability('marketplace'));
$this->assertFalse($theme->hasCapability('preview'));
}
public function test_unknown_manifest_fields_are_preserved_without_preventing_loading(): void
{
$path = $this->themesPath.'/future';
mkdir($path.'/css', 0777, true);
file_put_contents($path.'/theme.json', json_encode([
'name' => 'Future',
'future_builder_metadata' => ['anything' => true],
]));
file_put_contents($path.'/css/theme.css', 'body{}');
$theme = app(Loader::class)->fromPath($path);
$this->assertSame(['anything' => true], $theme->meta()['future_builder_metadata']);
}
}
@@ -0,0 +1,79 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Http\UploadedFile;
use Modules\Stylist\Services\ThemePackageManager;
use Modules\Stylist\Theme\Loader;
use Tests\TestCase;
use ZipArchive;
final class ThemePackageRoundTripTest extends TestCase
{
private string $themesPath;
private string $workspace;
protected function setUp(): void
{
parent::setUp();
$this->themesPath = storage_path('framework/testing/stylist-installed-'.uniqid());
$this->workspace = storage_path('framework/testing/stylist-package-'.uniqid());
mkdir($this->themesPath, 0777, true);
mkdir($this->workspace, 0777, true);
config(['stylist.themes_path' => $this->themesPath]);
app(Loader::class)->flush();
}
protected function tearDown(): void
{
app(Filesystem::class)->deleteDirectory($this->themesPath);
app(Filesystem::class)->deleteDirectory($this->workspace);
app(Loader::class)->flush();
parent::tearDown();
}
public function test_generated_theme_zip_installs_discovers_and_renders(): void
{
$source = $this->workspace.'/design-gallery';
mkdir($source.'/css', 0777, true);
mkdir($source.'/js', 0777, true);
mkdir($source.'/views', 0777, true);
file_put_contents($source.'/theme.json', json_encode([
'manifest_version' => 1,
'key' => 'design-gallery',
'name' => 'Design Gallery',
'stylesheet' => 'css/app.css',
'script' => 'js/app.js',
'entry_view' => 'home',
'appearances' => ['light', 'dark', 'system'],
'accents' => ['blue', 'neutral'],
'default_appearance' => 'system',
'default_accent' => 'neutral',
]));
file_put_contents($source.'/css/app.css', 'body{}');
file_put_contents($source.'/js/app.js', '');
file_put_contents($source.'/views/home.blade.php', '<main>Design Gallery works</main>');
$zipPath = $this->workspace.'/design-gallery.zip';
$zip = new ZipArchive;
$this->assertTrue($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE));
foreach (app(Filesystem::class)->allFiles($source) as $file) {
$zip->addFile($file->getPathname(), 'design-gallery/'.$file->getRelativePathname());
}
$zip->close();
$upload = new UploadedFile($zipPath, 'design-gallery.zip', 'application/zip', null, true);
$theme = app(ThemePackageManager::class)->install($upload);
$this->assertSame('design-gallery', $theme->key());
$this->assertTrue(app(Loader::class)->all()->has('design-gallery'));
$this->assertStringContainsString(
'Design Gallery works',
stylist_theme_view('design-gallery', 'home')->render(),
);
}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Modules\Stylist\View\ThemeViewResolver;
use Tests\TestCase;
final class ThemeViewResolverTest extends TestCase
{
public function test_theme_view_candidates_prefer_active_theme_then_default(): void
{
config([
'stylist.default_theme' => 'backend',
'stylist.themes_path' => resource_path('themes'),
]);
$candidates = app(ThemeViewResolver::class)->candidates('components.page-hero');
$this->assertContains('stylist-theme-backend::components.page-hero', $candidates);
$this->assertContains('stylist::components.page-hero', $candidates);
}
}
@@ -0,0 +1,45 @@
<?php
namespace Modules\Stylist\Tests\Feature;
use Tests\TestCase;
final class WelcomeThemeViewTest extends TestCase
{
public function test_bundled_frontend_theme_view_namespace_is_registered(): void
{
$this->assertTrue(view()->exists('stylist-theme-frontend::welcome'));
}
public function test_dashboard_view_is_owned_by_the_backend_theme(): void
{
$this->assertStringContainsString(
resource_path('themes/backend/views/dashboard.blade.php'),
view('dashboard')->getPath(),
);
}
public function test_home_route_renders_from_the_frontend_theme(): void
{
$response = $this->get('/');
$response->assertOk()->assertViewIs('welcome');
$this->assertStringContainsString(
resource_path('themes/frontend/views/welcome.blade.php'),
view('welcome')->getPath(),
);
}
public function test_application_views_exist_only_inside_themes(): void
{
$this->assertDirectoryDoesNotExist(resource_path('views'));
$this->assertDirectoryDoesNotExist(resource_path('css'));
$this->assertDirectoryDoesNotExist(resource_path('js'));
}
public function test_backend_compiled_assets_are_owned_by_the_backend_theme(): void
{
$this->assertFileExists(resource_path('themes/backend/assets/css/app.css'));
$this->assertFileExists(resource_path('themes/backend/assets/js/passkeys.js'));
}
}
@@ -0,0 +1,18 @@
<?php
namespace Modules\Stylist\Theme\Exceptions;
use RuntimeException;
final class InvalidThemeManifestException extends RuntimeException
{
/** @param array<int, string> $errors */
public static function forPath(string $path, array $errors): self
{
return new self(sprintf(
'Invalid theme manifest at [%s]: %s',
rtrim($path, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'theme.json',
implode(' ', $errors),
));
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Stylist\Theme\Exceptions;
use RuntimeException;
final class ThemeJsonNotFoundException extends RuntimeException
{
public static function forPath(string $path): self
{
return new self("Theme manifest [{$path}/theme.json] was not found.");
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Stylist\Theme\Exceptions;
use RuntimeException;
final class ThemeNotFoundException extends RuntimeException
{
public static function forKey(string $key): self
{
return new self("Theme [{$key}] was not found.");
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Stylist\Theme;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Exceptions\ThemeJsonNotFoundException;
final class Json
{
public function __construct(private readonly Filesystem $files) {}
public function read(string $path): array
{
$manifest = rtrim($path, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'theme.json';
if (! $this->files->exists($manifest)) {
throw ThemeJsonNotFoundException::forPath($path);
}
return json_decode($this->files->get($manifest), true, flags: JSON_THROW_ON_ERROR);
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Modules\Stylist\Theme;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Collection;
use Modules\Stylist\Theme\Exceptions\ThemeNotFoundException;
final class Loader
{
private ?Collection $themes = null;
public function __construct(
private readonly Filesystem $files,
private readonly Json $json,
private readonly ManifestValidator $validator,
) {}
public function all(): Collection
{
if ($this->themes !== null) {
return $this->themes;
}
$themes = collect();
foreach ($this->roots() as $root) {
if (! $this->files->isDirectory($root)) {
continue;
}
foreach ($this->files->directories($root) as $themePath) {
try {
$theme = $this->fromPath($themePath);
$themes->put($theme->key(), $theme);
} catch (\Throwable $exception) {
report($exception);
}
}
}
return $this->themes = $themes->sortKeys();
}
public function find(string $key): Theme
{
$theme = $this->all()->get($key);
if (! $theme) {
throw ThemeNotFoundException::forKey($key);
}
return $theme;
}
public function fromPath(string $path): Theme
{
$data = $this->validator->validate($this->json->read($path), $path);
return new Theme(
key: $data['key'],
name: $data['name'],
path: rtrim($path, DIRECTORY_SEPARATOR),
stylesheet: $data['stylesheet'],
script: $data['script'],
variables: $data['variables'],
meta: $data,
);
}
public function flush(): void
{
$this->themes = null;
}
/** @return array<int, string> */
public function roots(): array
{
return array_values(array_unique(array_filter([
(string) config('stylist.themes_path'),
])));
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace Modules\Stylist\Theme;
use Modules\Stylist\Theme\Exceptions\InvalidThemeManifestException;
final class ManifestValidator
{
/** @return array<string, mixed> */
public function validate(array $manifest, string $path): array
{
$errors = [];
$manifestVersion = $manifest['manifest_version'] ?? $manifest['schema_version'] ?? 1;
if ((is_string($manifestVersion) && ctype_digit($manifestVersion))) {
$manifestVersion = (int) $manifestVersion;
}
if (! is_int($manifestVersion) || $manifestVersion < 1) {
$errors[] = 'The manifest_version must be a positive integer.';
}
$key = $manifest['key'] ?? $manifest['slug'] ?? basename($path);
if (! is_string($key) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $key)) {
$errors[] = 'The key/slug must contain only lowercase letters, numbers, dashes, and underscores.';
}
if (isset($manifest['name']) && (! is_string($manifest['name']) || trim($manifest['name']) === '')) {
$errors[] = 'The name must be a non-empty string.';
}
if (isset($manifest['version']) && (! is_string($manifest['version']) || ! preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $manifest['version']))) {
$errors[] = 'The version must use semantic versioning, for example 1.2.0.';
}
if (isset($manifest['type']) && ! in_array($manifest['type'], ['frontend', 'backend', 'common'], true)) {
$errors[] = 'The type must be frontend, backend, or common.';
}
$parent = $manifest['extends'] ?? $manifest['parent'] ?? null;
if ($parent !== null && (! is_string($parent) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $parent))) {
$errors[] = 'The extends/parent value must be a valid theme key.';
}
foreach (['stylesheet', 'script', 'preview'] as $field) {
if (isset($manifest[$field]) && ! $this->isSafeRelativePath($manifest[$field])) {
$errors[] = "The {$field} value must be a safe relative path.";
}
}
foreach (['assets', 'variables', 'supports', 'capabilities', 'variants', 'appearances', 'accents', 'settings', 'seo'] as $field) {
if (isset($manifest[$field]) && ! is_array($manifest[$field])) {
$errors[] = "The {$field} value must be an object or array.";
}
}
$variants = $this->normaliseDefinitions($manifest['variants'] ?? [], 'variants', $errors, false);
$appearances = $this->normaliseDefinitions($manifest['appearances'] ?? [], 'appearances', $errors);
$accents = $this->normaliseDefinitions($manifest['accents'] ?? [], 'accents', $errors);
$capabilities = $this->normaliseCapabilities($manifest['capabilities'] ?? [], $errors);
$capabilities['appearances'] = $appearances !== [];
$capabilities['accents'] = $accents !== [];
$capabilities['preview'] = isset($manifest['preview']) || is_file($path.'/preview.webp');
$defaultAppearance = $manifest['default_appearance'] ?? null;
if ($defaultAppearance !== null) {
if (! is_string($defaultAppearance) || $defaultAppearance === '') {
$errors[] = 'The default_appearance value must be a non-empty string.';
} elseif ($appearances === [] || ! array_key_exists($defaultAppearance, $appearances)) {
$errors[] = 'The default_appearance value must be declared in appearances.';
}
}
$defaultAccent = $manifest['default_accent'] ?? null;
if ($defaultAccent !== null) {
if (! is_string($defaultAccent) || $defaultAccent === '') {
$errors[] = 'The default_accent value must be a non-empty string.';
} elseif ($accents === [] || ! array_key_exists($defaultAccent, $accents)) {
$errors[] = 'The default_accent value must be declared in accents.';
}
}
if ($errors !== []) {
throw InvalidThemeManifestException::forPath($path, $errors);
}
$manifest['manifest_version'] = $manifestVersion;
unset($manifest['schema_version']);
$manifest['key'] = $key;
$manifest['slug'] = $key;
$manifest['name'] = $manifest['name'] ?? str($key)->headline()->toString();
$manifest['extends'] = $parent;
$manifest['version'] = (string) ($manifest['version'] ?? '1.0.0');
$manifest['entry_view'] = (string) ($manifest['entry_view'] ?? 'home');
$manifest['stylesheet'] = $manifest['stylesheet'] ?? $manifest['css'] ?? 'css/theme.css';
$manifest['script'] = $manifest['script'] ?? 'js/app.js';
$manifest['assets'] = is_array($manifest['assets'] ?? null) ? $manifest['assets'] : [];
$manifest['variables'] = is_array($manifest['variables'] ?? null) ? $manifest['variables'] : [];
$manifest['supports'] = is_array($manifest['supports'] ?? null) ? $manifest['supports'] : [];
$manifest['capabilities'] = $capabilities;
$manifest['variants'] = $variants;
$manifest['appearances'] = $appearances;
$manifest['accents'] = $accents;
$manifest['settings'] = is_array($manifest['settings'] ?? null) ? $manifest['settings'] : [];
$manifest['seo'] = is_array($manifest['seo'] ?? null) ? $manifest['seo'] : [];
$manifest['preview'] = $manifest['preview'] ?? (is_file($path.'/preview.webp') ? 'preview.webp' : null);
return $manifest;
}
/**
* Compact lists such as ["light", "dark"] are accepted and converted to
* keyed empty definitions. Object definitions remain unchanged.
*
* @param array<int|string, mixed> $value
* @param array<int, string> $errors
* @return array<string, array<string, mixed>>
*/
private function normaliseDefinitions(array $value, string $field, array &$errors, bool $allowCompact = true): array
{
if ($value === []) {
return [];
}
if (array_is_list($value)) {
if (! $allowCompact && $field === 'variants') {
// Older Theme Builder Phase 1 builds incorrectly wrote accent names
// to variants. Ignore that list while preserving object-style variants.
return [];
}
$normalised = [];
foreach ($value as $name) {
if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name)) {
$errors[] = "Every {$field} entry must be a valid lowercase key.";
continue;
}
$normalised[$name] = [];
}
return $normalised;
}
$normalised = [];
foreach ($value as $name => $definition) {
if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name) || ! is_array($definition)) {
$errors[] = "Every {$field} definition must use a valid lowercase key and an object value.";
continue;
}
$normalised[$name] = $definition;
}
return $normalised;
}
/**
* @param array<int|string, mixed> $value
* @param array<int, string> $errors
* @return array<string, bool>
*/
private function normaliseCapabilities(array $value, array &$errors): array
{
$normalised = [];
foreach ($value as $name => $enabled) {
if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name) || ! is_bool($enabled)) {
$errors[] = 'Every capability must use a valid lowercase key and a boolean value.';
continue;
}
$normalised[$name] = $enabled;
}
return $normalised;
}
private function isSafeRelativePath(mixed $path): bool
{
return is_string($path) && $path !== '' && ! str_starts_with($path, '/') && ! str_contains($path, '..') && ! str_contains($path, "\0");
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace Modules\Stylist\Theme;
use Modules\Stylist\Services\TenantThemeResolver;
use Modules\Stylist\Services\ThemeSettings;
use Modules\Stylist\Theme\Exceptions\ThemeNotFoundException;
final class Stylist
{
public function __construct(private readonly Loader $loader, private readonly TenantThemeResolver $resolver, private readonly ThemeSettings $settings) {}
public function current(?string $context = null): Theme
{
$requested = $this->resolver->resolve($context);
try {
return $this->loader->find($requested);
} catch (ThemeNotFoundException) {
$fallback = $context === 'frontend'
? (string) config('stylist.default_frontend_theme', 'frontend')
: (string) config('stylist.default_backend_theme', config('stylist.default_theme', 'backend'));
if ($requested !== $fallback) {
return $this->loader->find($fallback);
}
throw ThemeNotFoundException::forKey($requested);
}
}
public function frontend(): Theme
{
return $this->current('frontend');
}
public function backend(): Theme
{
return $this->current('backend');
}
public function variables(): array
{
$theme = $this->current();
$variant = $this->settings->variant($theme);
$variantVars = (array) data_get($theme->variants(), $variant.'.variables', []);
$appearanceVars = (array) data_get($theme->appearances(), $this->appearance().'.variables', []);
$accentVars = (array) data_get($theme->accents(), $this->accent().'.variables', []);
$settingVars = collect($this->settings->get($theme))
->filter(fn ($value, $key) => is_string($key) && str_starts_with($key, '--'))
->mapWithKeys(fn ($value, $key) => [ltrim($key, '-') => $value])
->all();
return array_replace(
(array) config('stylist.fallback_variables', []),
$this->inheritedVariables($theme),
$variantVars,
$appearanceVars,
$accentVars,
$settingVars,
$this->resolver->variables(),
);
}
public function settings(): array
{
return $this->settings->get($this->current());
}
public function variant(): string
{
return $this->settings->variant($this->current());
}
public function appearance(): string
{
return $this->settings->appearance($this->current());
}
public function accent(): string
{
return $this->settings->accent($this->current());
}
public function all()
{
return $this->loader->all();
}
private function inheritedVariables(Theme $theme, array $seen = []): array
{
if (in_array($theme->key(), $seen, true)) {
return [];
}
$seen[] = $theme->key();
$parent = [];
if ($theme->parent()) {
try {
$parent = $this->inheritedVariables($this->loader->find($theme->parent()), $seen);
} catch (ThemeNotFoundException) {
}
}
return array_replace($parent, $theme->variables());
}
}
+161
View File
@@ -0,0 +1,161 @@
<?php
namespace Modules\Stylist\Theme;
final class Theme
{
public function __construct(
private readonly string $key,
private readonly string $name,
private readonly string $path,
private readonly string $stylesheet = 'css/theme.css',
private readonly string $script = 'js/app.js',
private readonly array $variables = [],
private readonly array $meta = [],
) {}
public function key(): string
{
return $this->key;
}
public function name(): string
{
return $this->name;
}
public function path(): string
{
return $this->path;
}
public function stylesheet(): string
{
return $this->stylesheet;
}
public function script(): string
{
return $this->script;
}
public function variables(): array
{
return $this->variables;
}
public function meta(): array
{
return $this->meta;
}
public function manifestVersion(): int
{
return (int) ($this->meta['manifest_version'] ?? 1);
}
public function version(): string
{
return (string) ($this->meta['version'] ?? '1.0.0');
}
public function description(): string
{
return (string) ($this->meta['description'] ?? '');
}
public function type(): string
{
return (string) ($this->meta['type'] ?? 'frontend');
}
public function author(): string
{
return (string) ($this->meta['author'] ?? '');
}
public function parent(): ?string
{
$parent = $this->meta['extends'] ?? null;
return is_string($parent) && $parent !== '' ? $parent : null;
}
public function preview(): ?string
{
$preview = $this->meta['preview'] ?? null;
return is_string($preview) && $preview !== '' ? $preview : null;
}
public function variants(): array
{
return (array) ($this->meta['variants'] ?? []);
}
public function appearances(): array
{
return (array) ($this->meta['appearances'] ?? []);
}
public function accents(): array
{
return (array) ($this->meta['accents'] ?? []);
}
public function defaultAppearance(): string
{
return (string) ($this->meta['default_appearance'] ?? array_key_first($this->appearances()) ?? '');
}
public function defaultAccent(): string
{
return (string) ($this->meta['default_accent'] ?? array_key_first($this->accents()) ?? '');
}
public function settingsSchema(): array
{
return (array) ($this->meta['settings'] ?? []);
}
public function seo(): array
{
return (array) ($this->meta['seo'] ?? []);
}
public function entryView(): string
{
return (string) ($this->meta['entry_view'] ?? 'home');
}
public function capabilities(): array
{
return (array) ($this->meta['capabilities'] ?? []);
}
public function hasCapability(string $feature): bool
{
return (bool) ($this->capabilities()[$feature] ?? false);
}
public function supports(string $feature): bool
{
return (bool) data_get($this->meta, 'supports.'.$feature, $this->hasCapability($feature));
}
public function has(string $relativePath): bool
{
return is_file($this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/'));
}
public function absolutePath(string $relativePath): string
{
return $this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/');
}
/** @deprecated Theme assets are served through UrlGenerator. */
public function assetsPath(): string
{
return 'stylist/theme-assets/'.$this->key;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Stylist\Theme;
final class UrlGenerator
{
public function __construct(private readonly Stylist $stylist) {}
public function asset(string $path): string
{
return $this->forTheme($this->stylist->current(), $path);
}
public function forTheme(Theme|string $theme, string $path): string
{
$key = $theme instanceof Theme ? $theme->key() : $theme;
return route('stylist.theme.asset', ['theme' => $key, 'path' => ltrim($path, '/')]);
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace Modules\Stylist\View;
use Illuminate\Contracts\View\Factory as ViewFactory;
use Illuminate\Contracts\View\View;
use Modules\Stylist\Theme\Exceptions\ThemeNotFoundException;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Stylist;
use RuntimeException;
final class ThemeViewResolver
{
/** @var array<string, array<int, string>> */
private array $candidateCache = [];
public function __construct(
private readonly Stylist $stylist,
private readonly ViewFactory $views,
private readonly Loader $loader,
) {}
public function make(string $view, array $data = [], array $mergeData = []): View
{
return $this->views->first(
$this->candidates($view),
array_replace([
'themeSettings' => $this->stylist->settings(),
'themeVariant' => $this->stylist->variant(),
], $data),
$mergeData,
);
}
public function makeForTheme(string $theme, string $view, array $data = [], array $mergeData = []): View
{
return $this->views->first($this->candidatesForTheme($theme, $view), $data, $mergeData);
}
/** @return array<int, string> */
public function candidates(string $view): array
{
return $this->candidatesForTheme($this->stylist->current()->key(), $view);
}
/** @return array<int, string> */
/** @return array<int, string> */
public function themedCandidatesForTheme(string $theme, string $view): array
{
return array_values(array_filter(
$this->candidatesForTheme($theme, $view),
fn (string $candidate): bool => str_contains($candidate, '::'),
));
}
public function candidatesForTheme(string $theme, string $view): array
{
$view = ltrim($view, '.');
$cacheKey = $theme.'|'.$view;
if (isset($this->candidateCache[$cacheKey])) {
return $this->candidateCache[$cacheKey];
}
$keys = $this->inheritanceChain($theme);
$fallback = (string) config('stylist.default_theme', 'common');
if ($fallback !== '' && ! in_array($fallback, $keys, true)) {
try {
$keys = array_merge($keys, $this->inheritanceChain($fallback));
} catch (ThemeNotFoundException) {
// A missing configured fallback is diagnosed by stylist:doctor.
}
}
if (! in_array('common', $keys, true)) {
try {
$keys = array_merge($keys, $this->inheritanceChain('common'));
} catch (ThemeNotFoundException) {
// The module fallback below remains available.
}
}
$candidates = [];
foreach (array_values(array_unique($keys)) as $key) {
$this->registerNamespace($key);
$candidates[] = "stylist-theme-{$key}::{$view}";
$candidates[] = "stylist-module-theme-{$key}::{$view}";
}
$candidates[] = "stylist::{$view}";
$candidates[] = $view;
return $this->candidateCache[$cacheKey] = array_values(array_unique($candidates));
}
public function flush(): void
{
$this->candidateCache = [];
}
/** @return array<int, string> */
private function inheritanceChain(string $theme): array
{
$keys = [];
$seen = [];
$key = $theme;
while ($key !== '') {
if (isset($seen[$key])) {
throw new RuntimeException('Theme inheritance cycle detected at ['.$key.'].');
}
$seen[$key] = true;
$loaded = $this->loader->find($key);
$keys[] = $loaded->key();
$key = $loaded->parent() ?? '';
}
return $keys;
}
private function registerNamespace(string $theme): void
{
try {
$loaded = $this->loader->find($theme);
} catch (ThemeNotFoundException) {
return;
}
$viewsPath = $loaded->path().DIRECTORY_SEPARATOR.'views';
if (! is_dir($viewsPath)) {
return;
}
$this->views->addNamespace("stylist-theme-{$theme}", $viewsPath);
$this->views->addNamespace("stylist-module-theme-{$theme}", $viewsPath);
}
}
+24
View File
@@ -0,0 +1,24 @@
latest: 1.1.0
releases:
- version: 1.1.0
date: 2026-07-08
changes:
- Added self-contained theme folders with theme.json, CSS, Blade views, and Livewire views.
- Added ThemeViewResolver and stylist_view() fallback helper.
- Added default and MPBA example themes.
- Added theme publishing support.
- Updated Vite inputs to include theme CSS files.
- version: 1.0.0
date: 2026-07-08
changes:
- Initial Laravel 13 Stylist module.
- version: 1.1.0
date: 2026-07-23
changes:
- Added optional manifest_version/schema_version normalisation.
- Added capability normalisation for appearances, accents, previews and future features.
- Added stylist:validate for installed themes, directories and ZIP packages.
- Added complete package validation during installation.
- Added dynamic view namespace registration after installing a theme.
- Added manifest compatibility and install-render regression coverage.
+22
View File
@@ -0,0 +1,22 @@
{
"name": "mpba/stylist-module",
"description": "Tenant-aware theme and asset switching module for Laravel 13.",
"type": "laravel-module",
"require": {
"php": "^8.3",
"illuminate/support": "^13.0",
"illuminate/view": "^13.0",
"illuminate/filesystem": "^13.0",
"illuminate/console": "^13.0"
},
"autoload": {
"psr-4": {
"Modules\\Stylist\\": ""
}
},
"autoload-dev": {
"psr-4": {
"Modules\\Stylist\\Tests\\": "Tests/"
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "Stylist",
"alias": "stylist",
"version": "1.1.0",
"description": "Tenant-aware theme and asset switching module for Laravel 13.",
"keywords": [
"laravel",
"modules",
"themes",
"tenancy",
"styling"
],
"priority": 0,
"providers": [
"Modules\\Stylist\\Providers\\StylistServiceProvider"
],
"aliases": {},
"files": [],
"requires": []
}
+15
View File
@@ -0,0 +1,15 @@
{
"scripts": {
"dev": "vite",
"build": "vite build",
"stylist:dev": "vite --config Modules/Stylist/vite.config.js",
"stylist:build": "vite build --config Modules/Stylist/vite.config.js",
"matches:dev": "vite --config Modules/Matches/vite.config.js",
"matches:build": "vite build --config Modules/Matches/vite.config.js",
"modules:build": "npm run stylist:build && npm run matches:build",
"build:all": "npm run build && npm run modules:build"
}
}

Some files were not shown because too many files have changed in this diff Show More