committed by
GitHub
commit
2312629d6a
578 changed files with 46773 additions and 0 deletions
@ -0,0 +1,436 @@ |
|||||
|
--- |
||||
|
name: ai-sdk-development |
||||
|
description: TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for other AI packages used directly. |
||||
|
license: MIT |
||||
|
metadata: |
||||
|
author: laravel |
||||
|
--- |
||||
|
|
||||
|
# Developing with the Laravel AI SDK |
||||
|
|
||||
|
The Laravel AI SDK (`laravel/ai`) is the official AI package for Laravel, providing a unified API for agents, images, audio, transcription, embeddings, reranking, vector stores, and file management across multiple AI providers. |
||||
|
|
||||
|
## Searching the Documentation |
||||
|
|
||||
|
This package is new. Always search the documentation before implementing any feature. Never guess at APIs — the documentation is the single source of truth. |
||||
|
|
||||
|
- Use broad, simple queries that match the documentation section headings below. |
||||
|
- Do not add package names to queries — package information is shared automatically. Use `test agent fake`, not `laravel ai test agent fake`. |
||||
|
- Run multiple queries at once — the most relevant results are returned first. |
||||
|
|
||||
|
### Documentation Sections |
||||
|
|
||||
|
Use these section headings as query terms for accurate results: |
||||
|
|
||||
|
- Introduction, Installation, Configuration, Provider Support |
||||
|
- Agents: Prompting, Conversation Context, Structured Output, Attachments, Streaming, Broadcasting, Queueing, Tools, Provider Tools, Middleware, Anonymous Agents, Agent Configuration |
||||
|
- Images |
||||
|
- Audio (TTS) |
||||
|
- Transcription (STT) |
||||
|
- Embeddings: Querying Embeddings, Caching Embeddings |
||||
|
- Reranking |
||||
|
- Files |
||||
|
- Vector Stores: Adding Files to Stores |
||||
|
- Failover |
||||
|
- Testing: Agents, Images, Audio, Transcriptions, Embeddings, Reranking, Files, Vector Stores |
||||
|
- Events |
||||
|
|
||||
|
## Decision Workflow |
||||
|
|
||||
|
Determine the right entry point before writing code: |
||||
|
|
||||
|
Text generation or chat? → Agent class with `Promptable` trait |
||||
|
Chat with conversation history? → Agent + `Conversational` interface (manual) or `RemembersConversations` trait (automatic) |
||||
|
Structured JSON output? → Agent + `HasStructuredOutput` interface |
||||
|
Image generation? → `Image::of()->generate()` |
||||
|
Audio synthesis? → `Audio::of()->generate()` |
||||
|
Transcription? → `Transcription::fromPath()->generate()` |
||||
|
Embeddings? → `Embeddings::for()->generate()` |
||||
|
Reranking? → `Reranking::of()->rerank()` |
||||
|
File storage? → `Document::fromPath()->put()` |
||||
|
Vector stores? → `Stores::create()` |
||||
|
|
||||
|
## Basic Usage Examples |
||||
|
|
||||
|
### Agents |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Contracts\Agent; |
||||
|
use Laravel\Ai\Enums\Lab; |
||||
|
use Laravel\Ai\Promptable; |
||||
|
|
||||
|
class SalesCoach implements Agent |
||||
|
{ |
||||
|
use Promptable; |
||||
|
|
||||
|
public function instructions(): string |
||||
|
{ |
||||
|
return 'You are a sales coach.'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Prompting |
||||
|
$response = (new SalesCoach)->prompt('Analyze this transcript...'); |
||||
|
echo $response->text; |
||||
|
|
||||
|
// Container resolution with dependency injection |
||||
|
$agent = SalesCoach::make(user: $user); |
||||
|
|
||||
|
// Override provider, model, or timeout per-prompt |
||||
|
$response = (new SalesCoach)->prompt( |
||||
|
'Analyze this transcript...', |
||||
|
provider: Lab::Anthropic, |
||||
|
model: 'claude-haiku-4-5-20251001', |
||||
|
timeout: 120, |
||||
|
); |
||||
|
|
||||
|
// Streaming (returns SSE response from a route) |
||||
|
return (new SalesCoach)->stream('Analyze this transcript...'); |
||||
|
|
||||
|
// Queueing |
||||
|
(new SalesCoach)->queue('Analyze this transcript...') |
||||
|
->then(fn ($response) => /* ... */); |
||||
|
|
||||
|
// Anonymous agents |
||||
|
use function Laravel\Ai\{agent}; |
||||
|
|
||||
|
$response = agent(instructions: 'You are a helpful assistant.')->prompt('Hello'); |
||||
|
``` |
||||
|
|
||||
|
### Conversation Context |
||||
|
|
||||
|
Manual conversation history via the `Conversational` interface: |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Contracts\Agent; |
||||
|
use Laravel\Ai\Contracts\Conversational; |
||||
|
use Laravel\Ai\Messages\Message; |
||||
|
use Laravel\Ai\Promptable; |
||||
|
|
||||
|
class SalesCoach implements Agent, Conversational |
||||
|
{ |
||||
|
use Promptable; |
||||
|
|
||||
|
public function __construct(public User $user) {} |
||||
|
|
||||
|
public function instructions(): string { return 'You are a sales coach.'; } |
||||
|
|
||||
|
public function messages(): iterable |
||||
|
{ |
||||
|
return History::where('user_id', $this->user->id) |
||||
|
->latest()->limit(50)->get()->reverse() |
||||
|
->map(fn ($m) => new Message($m->role, $m->content)) |
||||
|
->all(); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Automatic conversation persistence via the `RemembersConversations` trait: |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Concerns\RemembersConversations; |
||||
|
use Laravel\Ai\Contracts\Agent; |
||||
|
use Laravel\Ai\Contracts\Conversational; |
||||
|
use Laravel\Ai\Promptable; |
||||
|
|
||||
|
class SalesCoach implements Agent, Conversational |
||||
|
{ |
||||
|
use Promptable, RemembersConversations; |
||||
|
|
||||
|
public function instructions(): string { return 'You are a sales coach.'; } |
||||
|
} |
||||
|
|
||||
|
// Start a new conversation |
||||
|
$response = (new SalesCoach)->forUser($user)->prompt('Hello!'); |
||||
|
$conversationId = $response->conversationId; |
||||
|
|
||||
|
// Continue an existing conversation |
||||
|
$response = (new SalesCoach)->continue($conversationId, as: $user)->prompt('Tell me more.'); |
||||
|
``` |
||||
|
|
||||
|
### Structured Output |
||||
|
|
||||
|
```php |
||||
|
use Illuminate\Contracts\JsonSchema\JsonSchema; |
||||
|
use Laravel\Ai\Contracts\Agent; |
||||
|
use Laravel\Ai\Contracts\HasStructuredOutput; |
||||
|
use Laravel\Ai\Promptable; |
||||
|
|
||||
|
class Reviewer implements Agent, HasStructuredOutput |
||||
|
{ |
||||
|
use Promptable; |
||||
|
|
||||
|
public function instructions(): string { return 'Review and score content.'; } |
||||
|
|
||||
|
public function schema(JsonSchema $schema): array |
||||
|
{ |
||||
|
return [ |
||||
|
'feedback' => $schema->string()->required(), |
||||
|
'score' => $schema->integer()->min(1)->max(10)->required(), |
||||
|
]; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$response = (new Reviewer)->prompt('Review this...'); |
||||
|
echo $response['score']; // Access like an array |
||||
|
``` |
||||
|
|
||||
|
### Images |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Image; |
||||
|
|
||||
|
$image = Image::of('A sunset over mountains') |
||||
|
->landscape() |
||||
|
->quality('high') |
||||
|
->generate(); |
||||
|
|
||||
|
$path = $image->store(); // Store to default disk |
||||
|
``` |
||||
|
|
||||
|
### Audio |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Audio; |
||||
|
|
||||
|
$audio = Audio::of('Hello from Laravel.') |
||||
|
->female() |
||||
|
->instructions('Speak warmly') |
||||
|
->generate(); |
||||
|
|
||||
|
$path = $audio->store(); |
||||
|
``` |
||||
|
|
||||
|
### Transcription |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Transcription; |
||||
|
|
||||
|
$transcript = Transcription::fromStorage('audio.mp3') |
||||
|
->diarize() |
||||
|
->generate(); |
||||
|
|
||||
|
echo (string) $transcript; |
||||
|
``` |
||||
|
|
||||
|
### Embeddings |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Embeddings; |
||||
|
use Illuminate\Support\Str; |
||||
|
|
||||
|
$response = Embeddings::for(['Text one', 'Text two']) |
||||
|
->dimensions(1536) |
||||
|
->cache() |
||||
|
->generate(); |
||||
|
|
||||
|
// Single string via Stringable |
||||
|
$embedding = Str::of('Napa Valley has great wine.')->toEmbeddings(); |
||||
|
``` |
||||
|
|
||||
|
### Reranking |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Reranking; |
||||
|
|
||||
|
$response = Reranking::of(['Django is Python.', 'Laravel is PHP.', 'React is JS.']) |
||||
|
->limit(5) |
||||
|
->rerank('PHP frameworks'); |
||||
|
|
||||
|
$response->first()->document; // "Laravel is PHP." |
||||
|
``` |
||||
|
|
||||
|
### Files and Vector Stores |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Files\Document; |
||||
|
use Laravel\Ai\Stores; |
||||
|
|
||||
|
// Store a file with the provider |
||||
|
$file = Document::fromPath('/path/to/doc.pdf')->put(); |
||||
|
|
||||
|
// Create a vector store and add files |
||||
|
$store = Stores::create('Knowledge Base'); |
||||
|
$store->add($file->id); |
||||
|
$store->add(Document::fromStorage('manual.pdf')); // Store + add in one step |
||||
|
``` |
||||
|
|
||||
|
## Agent Configuration |
||||
|
|
||||
|
### PHP Attributes |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Attributes\{Provider, Model, MaxSteps, MaxTokens, Temperature, Timeout}; |
||||
|
use Laravel\Ai\Enums\Lab; |
||||
|
|
||||
|
#[Provider(Lab::Anthropic)] |
||||
|
#[Model('claude-haiku-4-5-20251001')] |
||||
|
#[MaxSteps(10)] |
||||
|
#[MaxTokens(4096)] |
||||
|
#[Temperature(0.7)] |
||||
|
#[Timeout(120)] |
||||
|
class MyAgent implements Agent |
||||
|
{ |
||||
|
use Promptable; |
||||
|
// ... |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
The `#[UseCheapestModel]` and `#[UseSmartestModel]` attributes are also available for automatic model selection. |
||||
|
|
||||
|
### Tools |
||||
|
|
||||
|
Implement the `HasTools` interface and scaffold tools with `php artisan make:tool`: |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Contracts\HasTools; |
||||
|
|
||||
|
class MyAgent implements Agent, HasTools |
||||
|
{ |
||||
|
use Promptable; |
||||
|
|
||||
|
public function tools(): iterable |
||||
|
{ |
||||
|
return [new MyCustomTool]; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Provider Tools |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Providers\Tools\{WebSearch, WebFetch, FileSearch}; |
||||
|
|
||||
|
public function tools(): iterable |
||||
|
{ |
||||
|
return [ |
||||
|
(new WebSearch)->max(5)->allow(['laravel.com']), |
||||
|
new WebFetch, |
||||
|
new FileSearch(stores: ['store_id']), |
||||
|
]; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Conversation Memory |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Concerns\RemembersConversations; |
||||
|
use Laravel\Ai\Contracts\Conversational; |
||||
|
|
||||
|
class ChatBot implements Agent, Conversational |
||||
|
{ |
||||
|
use Promptable, RemembersConversations; |
||||
|
// ... |
||||
|
} |
||||
|
|
||||
|
$response = (new ChatBot)->forUser($user)->prompt('Hello!'); |
||||
|
$response = (new ChatBot)->continue($conversationId, as: $user)->prompt('More...'); |
||||
|
``` |
||||
|
|
||||
|
### Failover |
||||
|
|
||||
|
```php |
||||
|
$response = (new MyAgent)->prompt('Hello', provider: [Lab::OpenAI, Lab::Anthropic]); |
||||
|
``` |
||||
|
|
||||
|
## Testing and Faking |
||||
|
|
||||
|
Each capability supports `fake()` with assertions: |
||||
|
|
||||
|
```php |
||||
|
use App\Ai\Agents\SalesCoach; |
||||
|
use Laravel\Ai\{Image, Audio, Transcription, Embeddings, Reranking, Files, Stores}; |
||||
|
|
||||
|
// Agents |
||||
|
SalesCoach::fake(['Response 1', 'Response 2']); |
||||
|
SalesCoach::assertPrompted('query'); |
||||
|
SalesCoach::assertNotPrompted('query'); |
||||
|
SalesCoach::assertNeverPrompted(); |
||||
|
SalesCoach::fake()->preventStrayPrompts(); |
||||
|
|
||||
|
// Images |
||||
|
Image::fake(); |
||||
|
Image::assertGenerated(fn ($prompt) => $prompt->contains('sunset')); |
||||
|
Image::assertNothingGenerated(); |
||||
|
|
||||
|
// Audio |
||||
|
Audio::fake(); |
||||
|
Audio::assertGenerated(fn ($prompt) => $prompt->contains('Hello')); |
||||
|
|
||||
|
// Transcription |
||||
|
Transcription::fake(['Transcribed text.']); |
||||
|
Transcription::assertGenerated(fn ($prompt) => $prompt->isDiarized()); |
||||
|
|
||||
|
// Embeddings |
||||
|
Embeddings::fake(); |
||||
|
Embeddings::assertGenerated(fn ($prompt) => $prompt->contains('Laravel')); |
||||
|
|
||||
|
// Reranking |
||||
|
Reranking::fake(); |
||||
|
Reranking::assertReranked(fn ($prompt) => $prompt->contains('PHP')); |
||||
|
|
||||
|
// Files |
||||
|
Files::fake(); |
||||
|
Files::assertStored(fn ($file) => $file->mimeType() === 'text/plain'); |
||||
|
|
||||
|
// Stores |
||||
|
Stores::fake(); |
||||
|
Stores::assertCreated('Knowledge Base'); |
||||
|
$store = Stores::get('id'); |
||||
|
$store->assertAdded('file_id'); |
||||
|
``` |
||||
|
|
||||
|
## Key Patterns |
||||
|
|
||||
|
- Namespace: `Laravel\Ai\` |
||||
|
- Package: `composer require laravel/ai` |
||||
|
- Agent pattern: Implement the `Agent` interface and use the `Promptable` trait |
||||
|
- Optional interfaces: `HasTools`, `HasMiddleware`, `HasStructuredOutput`, `Conversational` |
||||
|
- Entry-point classes: `Image`, `Audio`, `Transcription`, `Embeddings`, `Reranking`, `Stores` |
||||
|
- Provider enum: `Laravel\Ai\Enums\Lab` (prefer over plain strings) |
||||
|
- Artisan commands: `php artisan make:agent`, `php artisan make:tool` |
||||
|
- Global helper: `agent()` for anonymous agents |
||||
|
|
||||
|
## Common Pitfalls |
||||
|
|
||||
|
### Wrong Namespace |
||||
|
|
||||
|
The namespace is `Laravel\Ai`, not `Illuminate\Ai` or `Laravel\AI`. |
||||
|
|
||||
|
```php |
||||
|
// Correct |
||||
|
use Laravel\Ai\Image; |
||||
|
use Laravel\Ai\Contracts\Agent; |
||||
|
use Laravel\Ai\Promptable; |
||||
|
|
||||
|
// Wrong — these do not exist |
||||
|
use Illuminate\Ai\Image; |
||||
|
use Laravel\AI\Agent; |
||||
|
``` |
||||
|
|
||||
|
### Unsupported Provider Capability |
||||
|
|
||||
|
Calling a capability not supported by a provider throws a `LogicException`. Refer to the provider support table below. |
||||
|
|
||||
|
## Provider Support |
||||
|
|
||||
|
| Feature | Providers | |
||||
|
| ---------- | --------------------------------------------------------------- | |
||||
|
| Text | OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama | |
||||
|
| Images | OpenAI, Gemini, xAI | |
||||
|
| TTS | OpenAI, ElevenLabs | |
||||
|
| STT | OpenAI, ElevenLabs, Mistral | |
||||
|
| Embeddings | OpenAI, Gemini, Azure, Cohere, Mistral, Jina, VoyageAI | |
||||
|
| Reranking | Cohere, Jina | |
||||
|
| Files | OpenAI, Anthropic, Gemini | |
||||
|
|
||||
|
Use the `Laravel\Ai\Enums\Lab` enum to reference providers in code instead of plain strings: |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Enums\Lab; |
||||
|
|
||||
|
Lab::Anthropic; |
||||
|
Lab::OpenAI; |
||||
|
Lab::Gemini; |
||||
|
// ... |
||||
|
``` |
||||
@ -0,0 +1,230 @@ |
|||||
|
--- |
||||
|
name: laravel-backup |
||||
|
description: "Configure and extend spatie/laravel-backup for database and file backups, cleanup strategies, health monitoring, and notifications. Activates when working with backup configuration, scheduling backups, creating custom cleanup strategies or health checks, customizing notifications, or when the user mentions backups, backup monitoring, backup cleanup, or spatie/laravel-backup." |
||||
|
license: MIT |
||||
|
metadata: |
||||
|
author: spatie |
||||
|
--- |
||||
|
|
||||
|
# Laravel Backup |
||||
|
|
||||
|
## When to Apply |
||||
|
|
||||
|
Activate this skill when: |
||||
|
|
||||
|
- Configuring backup sources, destinations, or notifications |
||||
|
- Scheduling backup, cleanup, or monitor commands |
||||
|
- Creating custom cleanup strategies or health checks |
||||
|
- Customizing backup notifications |
||||
|
- Troubleshooting backup failures |
||||
|
|
||||
|
## Key Commands |
||||
|
|
||||
|
```bash |
||||
|
|
||||
|
# Run a backup |
||||
|
|
||||
|
php artisan backup:run |
||||
|
|
||||
|
# Backup only the database |
||||
|
|
||||
|
php artisan backup:run --only-db |
||||
|
|
||||
|
# Backup specific database connections |
||||
|
|
||||
|
php artisan backup:run --db-name=mysql --db-name=pgsql |
||||
|
|
||||
|
# Backup only files (no database) |
||||
|
|
||||
|
php artisan backup:run --only-files |
||||
|
|
||||
|
# Backup to a specific disk |
||||
|
|
||||
|
php artisan backup:run --only-to-disk=s3 |
||||
|
|
||||
|
# Custom filename |
||||
|
|
||||
|
php artisan backup:run --filename=my-backup.zip |
||||
|
|
||||
|
# Clean old backups |
||||
|
|
||||
|
php artisan backup:clean |
||||
|
|
||||
|
# List all backups |
||||
|
|
||||
|
php artisan backup:list |
||||
|
|
||||
|
# Monitor backup health |
||||
|
|
||||
|
php artisan backup:monitor |
||||
|
|
||||
|
# Use an alternative config key |
||||
|
|
||||
|
php artisan backup:run --config=backup_secondary |
||||
|
``` |
||||
|
|
||||
|
## Scheduling |
||||
|
|
||||
|
Add to `routes/console.php` or `app/Console/Kernel.php`: |
||||
|
|
||||
|
```php |
||||
|
use Illuminate\Support\Facades\Schedule; |
||||
|
|
||||
|
Schedule::command('backup:clean')->daily()->at('01:00'); |
||||
|
Schedule::command('backup:run')->daily()->at('01:30'); |
||||
|
Schedule::command('backup:monitor')->daily()->at('10:00'); |
||||
|
``` |
||||
|
|
||||
|
## Configuration |
||||
|
|
||||
|
Published to `config/backup.php` with sections: `backup` (source files/databases, destination disks, encryption), `notifications` (mail, Slack, Discord), `monitor_backups` (health checks), and `cleanup` (retention strategy). |
||||
|
|
||||
|
### Database Dump Customization |
||||
|
|
||||
|
Customize dumps per connection in `config/database.php`: |
||||
|
|
||||
|
```php |
||||
|
'mysql' => [ |
||||
|
// ... |
||||
|
'dump' => [ |
||||
|
'exclude_tables' => ['telescope_entries', 'telescope_monitoring'], |
||||
|
'useSingleTransaction' => true, |
||||
|
], |
||||
|
], |
||||
|
``` |
||||
|
|
||||
|
Enable dump compression: |
||||
|
|
||||
|
```php |
||||
|
// config/backup.php |
||||
|
'database_dump_compressor' => \Spatie\DbDumper\Compressors\GzipCompressor::class, |
||||
|
``` |
||||
|
|
||||
|
### Multiple Backup Destinations |
||||
|
|
||||
|
```php |
||||
|
// config/backup.php |
||||
|
'destination' => [ |
||||
|
'disks' => ['local', 's3'], |
||||
|
], |
||||
|
``` |
||||
|
|
||||
|
### Encryption |
||||
|
|
||||
|
```php |
||||
|
// config/backup.php |
||||
|
'password' => env('BACKUP_ARCHIVE_PASSWORD'), |
||||
|
'encryption' => 'default', // Uses ZipArchive::EM_AES_256 |
||||
|
``` |
||||
|
|
||||
|
## Custom Cleanup Strategy |
||||
|
|
||||
|
Extend `Spatie\Backup\Tasks\Cleanup\CleanupStrategy` and implement `deleteOldBackups`: |
||||
|
|
||||
|
```php |
||||
|
use Spatie\Backup\BackupDestination\BackupCollection; |
||||
|
use Spatie\Backup\Tasks\Cleanup\CleanupStrategy; |
||||
|
|
||||
|
class MyCleanupStrategy extends CleanupStrategy |
||||
|
{ |
||||
|
public function deleteOldBackups(BackupCollection $backups): void |
||||
|
{ |
||||
|
$backups |
||||
|
->reject(fn ($backup) => $backup->date()->gt(now()->subMonth())) |
||||
|
->each(fn ($backup) => $backup->delete()); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Register in `config/backup.php`: |
||||
|
|
||||
|
```php |
||||
|
'cleanup' => [ |
||||
|
'strategy' => \App\Backup\MyCleanupStrategy::class, |
||||
|
], |
||||
|
``` |
||||
|
|
||||
|
## Custom Health Check |
||||
|
|
||||
|
Extend `Spatie\Backup\Tasks\Monitor\HealthCheck` and implement `checkHealth`: |
||||
|
|
||||
|
```php |
||||
|
use Spatie\Backup\BackupDestination\BackupDestination; |
||||
|
use Spatie\Backup\Tasks\Monitor\HealthCheck; |
||||
|
|
||||
|
class MinimumBackupCount extends HealthCheck |
||||
|
{ |
||||
|
protected int $minimumCount; |
||||
|
|
||||
|
public function __construct(int $minimumCount = 3) |
||||
|
{ |
||||
|
$this->minimumCount = $minimumCount; |
||||
|
} |
||||
|
|
||||
|
public function checkHealth(BackupDestination $backupDestination): void |
||||
|
{ |
||||
|
$this->failIf( |
||||
|
$backupDestination->backups()->count() < $this->minimumCount, |
||||
|
"Expected at least {$this->minimumCount} backups." |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Register in `config/backup.php`: |
||||
|
|
||||
|
```php |
||||
|
'health_checks' => [ |
||||
|
\App\Backup\MinimumBackupCount::class => 5, |
||||
|
], |
||||
|
``` |
||||
|
|
||||
|
## Custom Notification |
||||
|
|
||||
|
Extend `Spatie\Backup\Notifications\BaseNotification`: |
||||
|
|
||||
|
```php |
||||
|
use Illuminate\Notifications\Messages\MailMessage; |
||||
|
use Spatie\Backup\Notifications\BaseNotification; |
||||
|
|
||||
|
class CustomBackupFailedNotification extends BaseNotification |
||||
|
{ |
||||
|
public function toMail(): MailMessage |
||||
|
{ |
||||
|
return (new MailMessage) |
||||
|
->error() |
||||
|
->subject("Backup failed for {$this->applicationName()}") |
||||
|
->line($this->event->exception->getMessage()); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Map it in `config/backup.php`: |
||||
|
|
||||
|
```php |
||||
|
'notifications' => [ |
||||
|
'notifications' => [ |
||||
|
\App\Notifications\CustomBackupFailedNotification::class => ['mail'], |
||||
|
], |
||||
|
], |
||||
|
``` |
||||
|
|
||||
|
## Events |
||||
|
|
||||
|
All events are in `Spatie\Backup\Events`: |
||||
|
|
||||
|
- `BackupWasSuccessful` - Backup completed successfully |
||||
|
- `BackupHasFailed` - Backup failed (includes exception and optional backup destination) |
||||
|
- `BackupManifestWasCreated` - File manifest created before zipping |
||||
|
- `BackupZipWasCreated` - Zip archive created (used for encryption hook) |
||||
|
- `DumpingDatabase` - Database dump in progress |
||||
|
- `CleanupWasSuccessful` / `CleanupHasFailed` - Cleanup result |
||||
|
- `HealthyBackupWasFound` / `UnhealthyBackupWasFound` - Monitor result |
||||
|
|
||||
|
## Common Pitfalls |
||||
|
|
||||
|
- Forgetting to schedule `backup:clean` alongside `backup:run`, causing disk space to fill up |
||||
|
- Not excluding `vendor` and `node_modules` from file backups (excluded by default) |
||||
|
- Setting `only-db` and `only-files` together (mutually exclusive) |
||||
|
- Missing the `ext-zip` PHP extension |
||||
|
- Not configuring the notification `mail.to` address after publishing config |
||||
@ -0,0 +1,190 @@ |
|||||
|
--- |
||||
|
name: laravel-best-practices |
||||
|
description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." |
||||
|
license: MIT |
||||
|
metadata: |
||||
|
author: laravel |
||||
|
--- |
||||
|
|
||||
|
# Laravel Best Practices |
||||
|
|
||||
|
Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`. |
||||
|
|
||||
|
## Consistency First |
||||
|
|
||||
|
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. |
||||
|
|
||||
|
Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. |
||||
|
|
||||
|
## Quick Reference |
||||
|
|
||||
|
### 1. Database Performance → `rules/db-performance.md` |
||||
|
|
||||
|
- Eager load with `with()` to prevent N+1 queries |
||||
|
- Enable `Model::preventLazyLoading()` in development |
||||
|
- Select only needed columns, avoid `SELECT *` |
||||
|
- `chunk()` / `chunkById()` for large datasets |
||||
|
- Index columns used in `WHERE`, `ORDER BY`, `JOIN` |
||||
|
- `withCount()` instead of loading relations to count |
||||
|
- `cursor()` for memory-efficient read-only iteration |
||||
|
- Never query in Blade templates |
||||
|
|
||||
|
### 2. Advanced Query Patterns → `rules/advanced-queries.md` |
||||
|
|
||||
|
- `addSelect()` subqueries over eager-loading entire has-many for a single value |
||||
|
- Dynamic relationships via subquery FK + `belongsTo` |
||||
|
- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries |
||||
|
- `setRelation()` to prevent circular N+1 queries |
||||
|
- `whereIn` + `pluck()` over `whereHas` for better index usage |
||||
|
- Two simple queries can beat one complex query |
||||
|
- Compound indexes matching `orderBy` column order |
||||
|
- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) |
||||
|
|
||||
|
### 3. Security → `rules/security.md` |
||||
|
|
||||
|
- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates |
||||
|
- No raw SQL with user input — use Eloquent or query builder |
||||
|
- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes |
||||
|
- Validate MIME type, extension, and size for file uploads |
||||
|
- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields |
||||
|
|
||||
|
### 4. Caching → `rules/caching.md` |
||||
|
|
||||
|
- `Cache::remember()` over manual get/put |
||||
|
- `Cache::flexible()` for stale-while-revalidate on high-traffic data |
||||
|
- `Cache::memo()` to avoid redundant cache hits within a request |
||||
|
- Cache tags to invalidate related groups |
||||
|
- `Cache::add()` for atomic conditional writes |
||||
|
- `once()` to memoize per-request or per-object lifetime |
||||
|
- `Cache::lock()` / `lockForUpdate()` for race conditions |
||||
|
- Failover cache stores in production |
||||
|
|
||||
|
### 5. Eloquent Patterns → `rules/eloquent.md` |
||||
|
|
||||
|
- Correct relationship types with return type hints |
||||
|
- Local scopes for reusable query constraints |
||||
|
- Global scopes sparingly — document their existence |
||||
|
- Attribute casts in the `casts()` method |
||||
|
- Cast date columns, use Carbon instances in templates |
||||
|
- `whereBelongsTo($model)` for cleaner queries |
||||
|
- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries |
||||
|
|
||||
|
### 6. Validation & Forms → `rules/validation.md` |
||||
|
|
||||
|
- Form Request classes, not inline validation |
||||
|
- Array notation `['required', 'email']` for new code; follow existing convention |
||||
|
- `$request->validated()` only — never `$request->all()` |
||||
|
- `Rule::when()` for conditional validation |
||||
|
- `after()` instead of `withValidator()` |
||||
|
|
||||
|
### 7. Configuration → `rules/config.md` |
||||
|
|
||||
|
- `env()` only inside config files |
||||
|
- `App::environment()` or `app()->isProduction()` |
||||
|
- Config, lang files, and constants over hardcoded text |
||||
|
|
||||
|
### 8. Testing Patterns → `rules/testing.md` |
||||
|
|
||||
|
- `LazilyRefreshDatabase` over `RefreshDatabase` for speed |
||||
|
- `assertModelExists()` over raw `assertDatabaseHas()` |
||||
|
- Factory states and sequences over manual overrides |
||||
|
- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before |
||||
|
- `recycle()` to share relationship instances across factories |
||||
|
|
||||
|
### 9. Queue & Job Patterns → `rules/queue-jobs.md` |
||||
|
|
||||
|
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` |
||||
|
- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release |
||||
|
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` |
||||
|
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs |
||||
|
- Horizon for complex multi-queue scenarios |
||||
|
|
||||
|
### 10. Routing & Controllers → `rules/routing.md` |
||||
|
|
||||
|
- Implicit route model binding |
||||
|
- Scoped bindings for nested resources |
||||
|
- `Route::resource()` or `apiResource()` |
||||
|
- Methods under 10 lines — extract to actions/services |
||||
|
- Type-hint Form Requests for auto-validation |
||||
|
|
||||
|
### 11. HTTP Client → `rules/http-client.md` |
||||
|
|
||||
|
- Explicit `timeout` and `connectTimeout` on every request |
||||
|
- `retry()` with exponential backoff for external APIs |
||||
|
- Check response status or use `throw()` |
||||
|
- `Http::pool()` for concurrent independent requests |
||||
|
- `Http::fake()` and `preventStrayRequests()` in tests |
||||
|
|
||||
|
### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` |
||||
|
|
||||
|
- Event discovery over manual registration; `event:cache` in production |
||||
|
- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions |
||||
|
- Queue notifications and mailables with `ShouldQueue` |
||||
|
- On-demand notifications for non-user recipients |
||||
|
- `HasLocalePreference` on notifiable models |
||||
|
- `assertQueued()` not `assertSent()` for queued mailables |
||||
|
- Markdown mailables for transactional emails |
||||
|
|
||||
|
### 13. Error Handling → `rules/error-handling.md` |
||||
|
|
||||
|
- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern |
||||
|
- `ShouldntReport` for exceptions that should never log |
||||
|
- Throttle high-volume exceptions to protect log sinks |
||||
|
- `dontReportDuplicates()` for multi-catch scenarios |
||||
|
- Force JSON rendering for API routes |
||||
|
- Structured context via `context()` on exception classes |
||||
|
|
||||
|
### 14. Task Scheduling → `rules/scheduling.md` |
||||
|
|
||||
|
- `withoutOverlapping()` on variable-duration tasks |
||||
|
- `onOneServer()` on multi-server deployments |
||||
|
- `runInBackground()` for concurrent long tasks |
||||
|
- `environments()` to restrict to appropriate environments |
||||
|
- `takeUntilTimeout()` for time-bounded processing |
||||
|
- Schedule groups for shared configuration |
||||
|
|
||||
|
### 15. Architecture → `rules/architecture.md` |
||||
|
|
||||
|
- Single-purpose Action classes; dependency injection over `app()` helper |
||||
|
- Prefer official Laravel packages and follow conventions, don't override defaults |
||||
|
- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety |
||||
|
- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution |
||||
|
|
||||
|
### 16. Migrations → `rules/migrations.md` |
||||
|
|
||||
|
- Generate migrations with `php artisan make:migration` |
||||
|
- `constrained()` for foreign keys |
||||
|
- Never modify migrations that have run in production |
||||
|
- Add indexes in the migration, not as an afterthought |
||||
|
- Mirror column defaults in model `$attributes` |
||||
|
- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes |
||||
|
- One concern per migration — never mix DDL and DML |
||||
|
|
||||
|
### 17. Collections → `rules/collections.md` |
||||
|
|
||||
|
- Higher-order messages for simple collection operations |
||||
|
- `cursor()` vs. `lazy()` — choose based on relationship needs |
||||
|
- `lazyById()` when updating records while iterating |
||||
|
- `toQuery()` for bulk operations on collections |
||||
|
|
||||
|
### 18. Blade & Views → `rules/blade-views.md` |
||||
|
|
||||
|
- `$attributes->merge()` in component templates |
||||
|
- Blade components over `@include`; `@pushOnce` for per-component scripts |
||||
|
- View Composers for shared view data |
||||
|
- `@aware` for deeply nested component props |
||||
|
|
||||
|
### 19. Conventions & Style → `rules/style.md` |
||||
|
|
||||
|
- Follow Laravel naming conventions for all entities |
||||
|
- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions |
||||
|
- No JS/CSS in Blade, no HTML in PHP classes |
||||
|
- Code should be readable; comments only for config files |
||||
|
|
||||
|
## How to Apply |
||||
|
|
||||
|
Always use a sub-agent to read rule files and explore this skill's content. |
||||
|
|
||||
|
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) |
||||
|
2. Check sibling files for existing patterns — follow those first per Consistency First |
||||
|
3. Verify API syntax with `search-docs` for the installed Laravel version |
||||
@ -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 execute(array $data): Order |
||||
|
{ |
||||
|
$order = Order::create($data); |
||||
|
$this->inventory->reserve($order); |
||||
|
|
||||
|
return $order; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Use Dependency Injection |
||||
|
|
||||
|
Always use constructor injection. Avoid `app()` or `resolve()` inside classes. |
||||
|
|
||||
|
Incorrect: |
||||
|
```php |
||||
|
class OrderController extends Controller |
||||
|
{ |
||||
|
public function store(StoreOrderRequest $request) |
||||
|
{ |
||||
|
$service = app(OrderService::class); |
||||
|
|
||||
|
return $service->create($request->validated()); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Correct: |
||||
|
```php |
||||
|
class OrderController extends Controller |
||||
|
{ |
||||
|
public function __construct(private OrderService $service) {} |
||||
|
|
||||
|
public function store(StoreOrderRequest $request) |
||||
|
{ |
||||
|
return $this->service->create($request->validated()); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Code to Interfaces |
||||
|
|
||||
|
Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. |
||||
|
|
||||
|
Incorrect (concrete dependency): |
||||
|
```php |
||||
|
class OrderService |
||||
|
{ |
||||
|
public function __construct(private StripeGateway $gateway) {} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Correct (interface dependency): |
||||
|
```php |
||||
|
interface PaymentGateway |
||||
|
{ |
||||
|
public function charge(int $amount, string $customerId): PaymentResult; |
||||
|
} |
||||
|
|
||||
|
class OrderService |
||||
|
{ |
||||
|
public function __construct(private PaymentGateway $gateway) {} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Bind in a service provider: |
||||
|
|
||||
|
```php |
||||
|
$this->app->bind(PaymentGateway::class, StripeGateway::class); |
||||
|
``` |
||||
|
|
||||
|
## Default Sort by Descending |
||||
|
|
||||
|
When no explicit order is specified, sort by `id` or `created_at` descending. 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,148 @@ |
|||||
|
# Eloquent Best Practices |
||||
|
|
||||
|
## Use Correct Relationship Types |
||||
|
|
||||
|
Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. |
||||
|
|
||||
|
```php |
||||
|
public function comments(): HasMany |
||||
|
{ |
||||
|
return $this->hasMany(Comment::class); |
||||
|
} |
||||
|
|
||||
|
public function author(): BelongsTo |
||||
|
{ |
||||
|
return $this->belongsTo(User::class, 'user_id'); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Use Local Scopes for Reusable Queries |
||||
|
|
||||
|
Extract reusable query constraints into local scopes to avoid duplication. |
||||
|
|
||||
|
Incorrect: |
||||
|
```php |
||||
|
$active = User::where('verified', true)->whereNotNull('activated_at')->get(); |
||||
|
$articles = Article::whereHas('user', function ($q) { |
||||
|
$q->where('verified', true)->whereNotNull('activated_at'); |
||||
|
})->get(); |
||||
|
``` |
||||
|
|
||||
|
Correct: |
||||
|
```php |
||||
|
public function scopeActive(Builder $query): Builder |
||||
|
{ |
||||
|
return $query->where('verified', true)->whereNotNull('activated_at'); |
||||
|
} |
||||
|
|
||||
|
// Usage |
||||
|
$active = User::active()->get(); |
||||
|
$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); |
||||
|
``` |
||||
|
|
||||
|
## Apply Global Scopes Sparingly |
||||
|
|
||||
|
Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. |
||||
|
|
||||
|
Incorrect (global scope for a conditional filter): |
||||
|
```php |
||||
|
class PublishedScope implements Scope |
||||
|
{ |
||||
|
public function apply(Builder $builder, Model $model): void |
||||
|
{ |
||||
|
$builder->where('published', true); |
||||
|
} |
||||
|
} |
||||
|
// Now admin panels, reports, and background jobs all silently skip drafts |
||||
|
``` |
||||
|
|
||||
|
Correct (local scope you opt into): |
||||
|
```php |
||||
|
public function scopePublished(Builder $query): Builder |
||||
|
{ |
||||
|
return $query->where('published', true); |
||||
|
} |
||||
|
|
||||
|
Post::published()->paginate(); // Explicit |
||||
|
Post::paginate(); // Admin sees all |
||||
|
``` |
||||
|
|
||||
|
## Define Attribute Casts |
||||
|
|
||||
|
Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. |
||||
|
|
||||
|
```php |
||||
|
protected function casts(): array |
||||
|
{ |
||||
|
return [ |
||||
|
'is_active' => 'boolean', |
||||
|
'metadata' => 'array', |
||||
|
'total' => 'decimal:2', |
||||
|
]; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Cast Date Columns Properly |
||||
|
|
||||
|
Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. |
||||
|
|
||||
|
Incorrect: |
||||
|
```blade |
||||
|
{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} |
||||
|
``` |
||||
|
|
||||
|
Correct: |
||||
|
```php |
||||
|
protected function casts(): array |
||||
|
{ |
||||
|
return [ |
||||
|
'ordered_at' => 'datetime', |
||||
|
]; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
```blade |
||||
|
{{ $order->ordered_at->toDateString() }} |
||||
|
{{ $order->ordered_at->format('m-d') }} |
||||
|
``` |
||||
|
|
||||
|
## Use `whereBelongsTo()` for Relationship Queries |
||||
|
|
||||
|
Cleaner than manually specifying foreign keys. |
||||
|
|
||||
|
Incorrect: |
||||
|
```php |
||||
|
Post::where('user_id', $user->id)->get(); |
||||
|
``` |
||||
|
|
||||
|
Correct: |
||||
|
```php |
||||
|
Post::whereBelongsTo($user)->get(); |
||||
|
Post::whereBelongsTo($user, 'author')->get(); |
||||
|
``` |
||||
|
|
||||
|
## Avoid Hardcoded Table Names in Queries |
||||
|
|
||||
|
Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). |
||||
|
|
||||
|
Incorrect: |
||||
|
```php |
||||
|
DB::table('users')->where('active', true)->get(); |
||||
|
|
||||
|
$query->join('companies', 'companies.id', '=', 'users.company_id'); |
||||
|
|
||||
|
DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); |
||||
|
``` |
||||
|
|
||||
|
Correct — reference the model's table: |
||||
|
```php |
||||
|
DB::table((new User)->getTable())->where('active', true)->get(); |
||||
|
|
||||
|
// Even better — use Eloquent or the query builder instead of raw SQL |
||||
|
User::where('active', true)->get(); |
||||
|
Order::where('status', 'pending')->get(); |
||||
|
``` |
||||
|
|
||||
|
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. |
||||
|
|
||||
|
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. |
||||
@ -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. In Inertia apps, the `@csrf` directive is automatically applied. |
||||
|
|
||||
|
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.'); |
||||
|
} |
||||
|
}, |
||||
|
]; |
||||
|
} |
||||
|
``` |
||||
@ -0,0 +1,277 @@ |
|||||
|
--- |
||||
|
name: laravel-permission-development |
||||
|
description: Build and work with Spatie Laravel Permission features, including roles, permissions, middleware, policies, teams, and Blade directives. |
||||
|
--- |
||||
|
|
||||
|
# Laravel Permission Development |
||||
|
|
||||
|
## When to use this skill |
||||
|
|
||||
|
Use this skill when working with authorization, roles, permissions, access control, middleware guards, or Blade permission directives using spatie/laravel-permission. |
||||
|
|
||||
|
## Core Concepts |
||||
|
|
||||
|
- **Users have Roles, Roles have Permissions, Apps check Permissions** (not Roles). |
||||
|
- Direct permissions on users are an anti-pattern; assign permissions to roles instead. |
||||
|
- Use `$user->can('permission-name')` for all authorization checks (supports Super Admin via Gate). |
||||
|
- The `HasRoles` trait (which includes `HasPermissions`) is added to User models. |
||||
|
|
||||
|
## Setup |
||||
|
|
||||
|
Add the `HasRoles` trait to your User model: |
||||
|
|
||||
|
```php |
||||
|
use Spatie\Permission\Traits\HasRoles; |
||||
|
|
||||
|
class User extends Authenticatable |
||||
|
{ |
||||
|
use HasRoles; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Creating Roles and Permissions |
||||
|
|
||||
|
```php |
||||
|
use Spatie\Permission\Models\Role; |
||||
|
use Spatie\Permission\Models\Permission; |
||||
|
|
||||
|
$role = Role::create(['name' => 'writer']); |
||||
|
$permission = Permission::create(['name' => 'edit articles']); |
||||
|
|
||||
|
// findOrCreate is idempotent (safe for seeders) |
||||
|
$role = Role::findOrCreate('writer', 'web'); |
||||
|
$permission = Permission::findOrCreate('edit articles', 'web'); |
||||
|
``` |
||||
|
|
||||
|
## Assigning Roles and Permissions |
||||
|
|
||||
|
```php |
||||
|
// Assign roles to users |
||||
|
$user->assignRole('writer'); |
||||
|
$user->assignRole('writer', 'admin'); |
||||
|
$user->assignRole(['writer', 'admin']); |
||||
|
$user->syncRoles(['writer', 'admin']); // replaces all |
||||
|
$user->removeRole('writer'); |
||||
|
|
||||
|
// Assign permissions to roles (preferred) |
||||
|
$role->givePermissionTo('edit articles'); |
||||
|
$role->givePermissionTo(['edit articles', 'delete articles']); |
||||
|
$role->syncPermissions(['edit articles', 'delete articles']); |
||||
|
$role->revokePermissionTo('edit articles'); |
||||
|
|
||||
|
// Reverse assignment |
||||
|
$permission->assignRole('writer'); |
||||
|
$permission->syncRoles(['writer', 'editor']); |
||||
|
$permission->removeRole('writer'); |
||||
|
``` |
||||
|
|
||||
|
## Checking Roles and Permissions |
||||
|
|
||||
|
```php |
||||
|
// Permission checks (preferred - supports Super Admin via Gate) |
||||
|
$user->can('edit articles'); |
||||
|
$user->canAny(['edit articles', 'delete articles']); |
||||
|
|
||||
|
// Direct package methods (bypass Gate, no Super Admin support) |
||||
|
$user->hasPermissionTo('edit articles'); |
||||
|
$user->hasAnyPermission(['edit articles', 'publish articles']); |
||||
|
$user->hasAllPermissions(['edit articles', 'publish articles']); |
||||
|
$user->hasDirectPermission('edit articles'); |
||||
|
|
||||
|
// Role checks |
||||
|
$user->hasRole('writer'); |
||||
|
$user->hasAnyRole(['writer', 'editor']); |
||||
|
$user->hasAllRoles(['writer', 'editor']); |
||||
|
$user->hasExactRoles(['writer', 'editor']); |
||||
|
|
||||
|
// Get assigned roles and permissions |
||||
|
$user->getRoleNames(); // Collection of role name strings |
||||
|
$user->getPermissionNames(); // Collection of permission name strings |
||||
|
$user->getDirectPermissions(); // Direct permissions only |
||||
|
$user->getPermissionsViaRoles(); // Inherited via roles |
||||
|
$user->getAllPermissions(); // Both direct and inherited |
||||
|
``` |
||||
|
|
||||
|
## Query Scopes |
||||
|
|
||||
|
```php |
||||
|
$users = User::role('writer')->get(); |
||||
|
$users = User::withoutRole('writer')->get(); |
||||
|
$users = User::permission('edit articles')->get(); |
||||
|
$users = User::withoutPermission('edit articles')->get(); |
||||
|
``` |
||||
|
|
||||
|
## Middleware |
||||
|
|
||||
|
Register middleware aliases in `bootstrap/app.php`: |
||||
|
|
||||
|
```php |
||||
|
->withMiddleware(function (Middleware $middleware) { |
||||
|
$middleware->alias([ |
||||
|
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, |
||||
|
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, |
||||
|
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, |
||||
|
]); |
||||
|
}) |
||||
|
``` |
||||
|
|
||||
|
Use in routes (pipe `|` for OR logic): |
||||
|
|
||||
|
```php |
||||
|
Route::middleware(['permission:edit articles'])->group(function () { ... }); |
||||
|
Route::middleware(['role:manager|writer'])->group(function () { ... }); |
||||
|
Route::middleware(['role_or_permission:manager|edit articles'])->group(function () { ... }); |
||||
|
|
||||
|
// With specific guard |
||||
|
Route::middleware(['role:manager,api'])->group(function () { ... }); |
||||
|
``` |
||||
|
|
||||
|
For single permissions, Laravel's built-in `can` middleware also works: |
||||
|
|
||||
|
```php |
||||
|
Route::middleware(['can:edit articles'])->group(function () { ... }); |
||||
|
``` |
||||
|
|
||||
|
## Blade Directives |
||||
|
|
||||
|
Prefer `@can` (permission-based) over `@role` (role-based): |
||||
|
|
||||
|
```blade |
||||
|
@can('edit articles') |
||||
|
{{-- User can edit articles (supports Super Admin) --}} |
||||
|
@endcan |
||||
|
|
||||
|
@canany(['edit articles', 'delete articles']) |
||||
|
{{-- User can do at least one --}} |
||||
|
@endcanany |
||||
|
|
||||
|
@role('admin') |
||||
|
{{-- Only use for super-admin type checks --}} |
||||
|
@endrole |
||||
|
|
||||
|
@hasanyrole('writer|admin') |
||||
|
{{-- Has writer or admin --}} |
||||
|
@endhasanyrole |
||||
|
``` |
||||
|
|
||||
|
## Super Admin |
||||
|
|
||||
|
Use `Gate::before` in `AppServiceProvider::boot()`: |
||||
|
|
||||
|
```php |
||||
|
use Illuminate\Support\Facades\Gate; |
||||
|
|
||||
|
public function boot(): void |
||||
|
{ |
||||
|
Gate::before(function ($user, $ability) { |
||||
|
return $user->hasRole('Super Admin') ? true : null; |
||||
|
}); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
This makes `$user->can()` and `@can` always return true for Super Admins. Must return `null` (not `false`) to allow normal checks for other users. |
||||
|
|
||||
|
## Policies |
||||
|
|
||||
|
Use `$user->can()` inside policy methods to check permissions: |
||||
|
|
||||
|
```php |
||||
|
class PostPolicy |
||||
|
{ |
||||
|
public function update(User $user, Post $post): bool |
||||
|
{ |
||||
|
if ($user->can('edit all posts')) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
return $user->can('edit own posts') && $user->id === $post->user_id; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Enums |
||||
|
|
||||
|
```php |
||||
|
enum RolesEnum: string |
||||
|
{ |
||||
|
case WRITER = 'writer'; |
||||
|
case EDITOR = 'editor'; |
||||
|
} |
||||
|
|
||||
|
enum PermissionsEnum: string |
||||
|
{ |
||||
|
case EDIT_POSTS = 'edit posts'; |
||||
|
case DELETE_POSTS = 'delete posts'; |
||||
|
} |
||||
|
|
||||
|
// Creation requires ->value |
||||
|
Permission::findOrCreate(PermissionsEnum::EDIT_POSTS->value, 'web'); |
||||
|
|
||||
|
// Most methods accept enums directly |
||||
|
$user->assignRole(RolesEnum::WRITER); |
||||
|
$user->hasRole(RolesEnum::WRITER); |
||||
|
$role->givePermissionTo(PermissionsEnum::EDIT_POSTS); |
||||
|
$user->hasPermissionTo(PermissionsEnum::EDIT_POSTS); |
||||
|
``` |
||||
|
|
||||
|
## Seeding |
||||
|
|
||||
|
Always flush the permission cache when seeding: |
||||
|
|
||||
|
```php |
||||
|
class RolesAndPermissionsSeeder extends Seeder |
||||
|
{ |
||||
|
public function run(): void |
||||
|
{ |
||||
|
// Reset cache |
||||
|
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); |
||||
|
|
||||
|
// Create permissions |
||||
|
Permission::findOrCreate('edit articles', 'web'); |
||||
|
Permission::findOrCreate('delete articles', 'web'); |
||||
|
|
||||
|
// Create roles and assign permissions |
||||
|
Role::findOrCreate('writer', 'web') |
||||
|
->givePermissionTo(['edit articles']); |
||||
|
|
||||
|
Role::findOrCreate('admin', 'web') |
||||
|
->givePermissionTo(Permission::all()); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Teams (Multi-Tenancy) |
||||
|
|
||||
|
Enable in `config/permission.php` before running migrations: |
||||
|
|
||||
|
```php |
||||
|
'teams' => true, |
||||
|
``` |
||||
|
|
||||
|
Set the active team in middleware: |
||||
|
|
||||
|
```php |
||||
|
setPermissionsTeamId($teamId); |
||||
|
``` |
||||
|
|
||||
|
When switching teams, unset cached relations: |
||||
|
|
||||
|
```php |
||||
|
$user->unsetRelation('roles')->unsetRelation('permissions'); |
||||
|
``` |
||||
|
|
||||
|
## Events |
||||
|
|
||||
|
Enable in `config/permission.php`: |
||||
|
|
||||
|
```php |
||||
|
'events_enabled' => true, |
||||
|
``` |
||||
|
|
||||
|
Available events: `RoleAttachedEvent`, `RoleDetachedEvent`, `PermissionAttachedEvent`, `PermissionDetachedEvent` in the `Spatie\Permission\Events` namespace. |
||||
|
|
||||
|
## Performance |
||||
|
|
||||
|
- Permissions are cached automatically. The cache is flushed when roles/permissions change via package methods. |
||||
|
- After direct DB operations, flush manually: `app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions()` |
||||
|
- For bulk seeding, use `Permission::insert()` for speed, but flush the cache afterward. |
||||
@ -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,11 @@ |
|||||
|
{ |
||||
|
"mcpServers": { |
||||
|
"laravel-boost": { |
||||
|
"command": "php", |
||||
|
"args": [ |
||||
|
"artisan", |
||||
|
"boost:mcp" |
||||
|
] |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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.yaml] |
||||
|
indent_size = 4 |
||||
@ -0,0 +1,65 @@ |
|||||
|
APP_NAME=Laravel |
||||
|
APP_ENV=local |
||||
|
APP_KEY= |
||||
|
APP_DEBUG=true |
||||
|
APP_URL=http://localhost |
||||
|
|
||||
|
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}" |
||||
@ -0,0 +1,11 @@ |
|||||
|
{ |
||||
|
"mcpServers": { |
||||
|
"laravel-boost": { |
||||
|
"command": "php", |
||||
|
"args": [ |
||||
|
"artisan", |
||||
|
"boost:mcp" |
||||
|
] |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
* text=auto eol=lf |
||||
|
|
||||
|
*.blade.php diff=html |
||||
|
*.css diff=css |
||||
|
*.html diff=html |
||||
|
*.md diff=markdown |
||||
|
*.php diff=php |
||||
|
|
||||
|
/.github export-ignore |
||||
|
CHANGELOG.md export-ignore |
||||
|
.styleci.yml export-ignore |
||||
@ -0,0 +1,25 @@ |
|||||
|
*.log |
||||
|
.DS_Store |
||||
|
.env |
||||
|
.env.backup |
||||
|
.env.production |
||||
|
.phpactor.json |
||||
|
.phpunit.result.cache |
||||
|
/.fleet |
||||
|
/.idea |
||||
|
/.nova |
||||
|
/.phpunit.cache |
||||
|
/.vscode |
||||
|
/.zed |
||||
|
/auth.json |
||||
|
/node_modules |
||||
|
/public/build |
||||
|
/public/hot |
||||
|
/public/storage |
||||
|
/storage/*.key |
||||
|
/storage/pail |
||||
|
/vendor |
||||
|
_ide_helper.php |
||||
|
Homestead.json |
||||
|
Homestead.yaml |
||||
|
Thumbs.db |
||||
@ -0,0 +1,436 @@ |
|||||
|
--- |
||||
|
name: ai-sdk-development |
||||
|
description: TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for other AI packages used directly. |
||||
|
license: MIT |
||||
|
metadata: |
||||
|
author: laravel |
||||
|
--- |
||||
|
|
||||
|
# Developing with the Laravel AI SDK |
||||
|
|
||||
|
The Laravel AI SDK (`laravel/ai`) is the official AI package for Laravel, providing a unified API for agents, images, audio, transcription, embeddings, reranking, vector stores, and file management across multiple AI providers. |
||||
|
|
||||
|
## Searching the Documentation |
||||
|
|
||||
|
This package is new. Always search the documentation before implementing any feature. Never guess at APIs — the documentation is the single source of truth. |
||||
|
|
||||
|
- Use broad, simple queries that match the documentation section headings below. |
||||
|
- Do not add package names to queries — package information is shared automatically. Use `test agent fake`, not `laravel ai test agent fake`. |
||||
|
- Run multiple queries at once — the most relevant results are returned first. |
||||
|
|
||||
|
### Documentation Sections |
||||
|
|
||||
|
Use these section headings as query terms for accurate results: |
||||
|
|
||||
|
- Introduction, Installation, Configuration, Provider Support |
||||
|
- Agents: Prompting, Conversation Context, Structured Output, Attachments, Streaming, Broadcasting, Queueing, Tools, Provider Tools, Middleware, Anonymous Agents, Agent Configuration |
||||
|
- Images |
||||
|
- Audio (TTS) |
||||
|
- Transcription (STT) |
||||
|
- Embeddings: Querying Embeddings, Caching Embeddings |
||||
|
- Reranking |
||||
|
- Files |
||||
|
- Vector Stores: Adding Files to Stores |
||||
|
- Failover |
||||
|
- Testing: Agents, Images, Audio, Transcriptions, Embeddings, Reranking, Files, Vector Stores |
||||
|
- Events |
||||
|
|
||||
|
## Decision Workflow |
||||
|
|
||||
|
Determine the right entry point before writing code: |
||||
|
|
||||
|
Text generation or chat? → Agent class with `Promptable` trait |
||||
|
Chat with conversation history? → Agent + `Conversational` interface (manual) or `RemembersConversations` trait (automatic) |
||||
|
Structured JSON output? → Agent + `HasStructuredOutput` interface |
||||
|
Image generation? → `Image::of()->generate()` |
||||
|
Audio synthesis? → `Audio::of()->generate()` |
||||
|
Transcription? → `Transcription::fromPath()->generate()` |
||||
|
Embeddings? → `Embeddings::for()->generate()` |
||||
|
Reranking? → `Reranking::of()->rerank()` |
||||
|
File storage? → `Document::fromPath()->put()` |
||||
|
Vector stores? → `Stores::create()` |
||||
|
|
||||
|
## Basic Usage Examples |
||||
|
|
||||
|
### Agents |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Contracts\Agent; |
||||
|
use Laravel\Ai\Enums\Lab; |
||||
|
use Laravel\Ai\Promptable; |
||||
|
|
||||
|
class SalesCoach implements Agent |
||||
|
{ |
||||
|
use Promptable; |
||||
|
|
||||
|
public function instructions(): string |
||||
|
{ |
||||
|
return 'You are a sales coach.'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Prompting |
||||
|
$response = (new SalesCoach)->prompt('Analyze this transcript...'); |
||||
|
echo $response->text; |
||||
|
|
||||
|
// Container resolution with dependency injection |
||||
|
$agent = SalesCoach::make(user: $user); |
||||
|
|
||||
|
// Override provider, model, or timeout per-prompt |
||||
|
$response = (new SalesCoach)->prompt( |
||||
|
'Analyze this transcript...', |
||||
|
provider: Lab::Anthropic, |
||||
|
model: 'claude-haiku-4-5-20251001', |
||||
|
timeout: 120, |
||||
|
); |
||||
|
|
||||
|
// Streaming (returns SSE response from a route) |
||||
|
return (new SalesCoach)->stream('Analyze this transcript...'); |
||||
|
|
||||
|
// Queueing |
||||
|
(new SalesCoach)->queue('Analyze this transcript...') |
||||
|
->then(fn ($response) => /* ... */); |
||||
|
|
||||
|
// Anonymous agents |
||||
|
use function Laravel\Ai\{agent}; |
||||
|
|
||||
|
$response = agent(instructions: 'You are a helpful assistant.')->prompt('Hello'); |
||||
|
``` |
||||
|
|
||||
|
### Conversation Context |
||||
|
|
||||
|
Manual conversation history via the `Conversational` interface: |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Contracts\Agent; |
||||
|
use Laravel\Ai\Contracts\Conversational; |
||||
|
use Laravel\Ai\Messages\Message; |
||||
|
use Laravel\Ai\Promptable; |
||||
|
|
||||
|
class SalesCoach implements Agent, Conversational |
||||
|
{ |
||||
|
use Promptable; |
||||
|
|
||||
|
public function __construct(public User $user) {} |
||||
|
|
||||
|
public function instructions(): string { return 'You are a sales coach.'; } |
||||
|
|
||||
|
public function messages(): iterable |
||||
|
{ |
||||
|
return History::where('user_id', $this->user->id) |
||||
|
->latest()->limit(50)->get()->reverse() |
||||
|
->map(fn ($m) => new Message($m->role, $m->content)) |
||||
|
->all(); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Automatic conversation persistence via the `RemembersConversations` trait: |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Concerns\RemembersConversations; |
||||
|
use Laravel\Ai\Contracts\Agent; |
||||
|
use Laravel\Ai\Contracts\Conversational; |
||||
|
use Laravel\Ai\Promptable; |
||||
|
|
||||
|
class SalesCoach implements Agent, Conversational |
||||
|
{ |
||||
|
use Promptable, RemembersConversations; |
||||
|
|
||||
|
public function instructions(): string { return 'You are a sales coach.'; } |
||||
|
} |
||||
|
|
||||
|
// Start a new conversation |
||||
|
$response = (new SalesCoach)->forUser($user)->prompt('Hello!'); |
||||
|
$conversationId = $response->conversationId; |
||||
|
|
||||
|
// Continue an existing conversation |
||||
|
$response = (new SalesCoach)->continue($conversationId, as: $user)->prompt('Tell me more.'); |
||||
|
``` |
||||
|
|
||||
|
### Structured Output |
||||
|
|
||||
|
```php |
||||
|
use Illuminate\Contracts\JsonSchema\JsonSchema; |
||||
|
use Laravel\Ai\Contracts\Agent; |
||||
|
use Laravel\Ai\Contracts\HasStructuredOutput; |
||||
|
use Laravel\Ai\Promptable; |
||||
|
|
||||
|
class Reviewer implements Agent, HasStructuredOutput |
||||
|
{ |
||||
|
use Promptable; |
||||
|
|
||||
|
public function instructions(): string { return 'Review and score content.'; } |
||||
|
|
||||
|
public function schema(JsonSchema $schema): array |
||||
|
{ |
||||
|
return [ |
||||
|
'feedback' => $schema->string()->required(), |
||||
|
'score' => $schema->integer()->min(1)->max(10)->required(), |
||||
|
]; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$response = (new Reviewer)->prompt('Review this...'); |
||||
|
echo $response['score']; // Access like an array |
||||
|
``` |
||||
|
|
||||
|
### Images |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Image; |
||||
|
|
||||
|
$image = Image::of('A sunset over mountains') |
||||
|
->landscape() |
||||
|
->quality('high') |
||||
|
->generate(); |
||||
|
|
||||
|
$path = $image->store(); // Store to default disk |
||||
|
``` |
||||
|
|
||||
|
### Audio |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Audio; |
||||
|
|
||||
|
$audio = Audio::of('Hello from Laravel.') |
||||
|
->female() |
||||
|
->instructions('Speak warmly') |
||||
|
->generate(); |
||||
|
|
||||
|
$path = $audio->store(); |
||||
|
``` |
||||
|
|
||||
|
### Transcription |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Transcription; |
||||
|
|
||||
|
$transcript = Transcription::fromStorage('audio.mp3') |
||||
|
->diarize() |
||||
|
->generate(); |
||||
|
|
||||
|
echo (string) $transcript; |
||||
|
``` |
||||
|
|
||||
|
### Embeddings |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Embeddings; |
||||
|
use Illuminate\Support\Str; |
||||
|
|
||||
|
$response = Embeddings::for(['Text one', 'Text two']) |
||||
|
->dimensions(1536) |
||||
|
->cache() |
||||
|
->generate(); |
||||
|
|
||||
|
// Single string via Stringable |
||||
|
$embedding = Str::of('Napa Valley has great wine.')->toEmbeddings(); |
||||
|
``` |
||||
|
|
||||
|
### Reranking |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Reranking; |
||||
|
|
||||
|
$response = Reranking::of(['Django is Python.', 'Laravel is PHP.', 'React is JS.']) |
||||
|
->limit(5) |
||||
|
->rerank('PHP frameworks'); |
||||
|
|
||||
|
$response->first()->document; // "Laravel is PHP." |
||||
|
``` |
||||
|
|
||||
|
### Files and Vector Stores |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Files\Document; |
||||
|
use Laravel\Ai\Stores; |
||||
|
|
||||
|
// Store a file with the provider |
||||
|
$file = Document::fromPath('/path/to/doc.pdf')->put(); |
||||
|
|
||||
|
// Create a vector store and add files |
||||
|
$store = Stores::create('Knowledge Base'); |
||||
|
$store->add($file->id); |
||||
|
$store->add(Document::fromStorage('manual.pdf')); // Store + add in one step |
||||
|
``` |
||||
|
|
||||
|
## Agent Configuration |
||||
|
|
||||
|
### PHP Attributes |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Attributes\{Provider, Model, MaxSteps, MaxTokens, Temperature, Timeout}; |
||||
|
use Laravel\Ai\Enums\Lab; |
||||
|
|
||||
|
#[Provider(Lab::Anthropic)] |
||||
|
#[Model('claude-haiku-4-5-20251001')] |
||||
|
#[MaxSteps(10)] |
||||
|
#[MaxTokens(4096)] |
||||
|
#[Temperature(0.7)] |
||||
|
#[Timeout(120)] |
||||
|
class MyAgent implements Agent |
||||
|
{ |
||||
|
use Promptable; |
||||
|
// ... |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
The `#[UseCheapestModel]` and `#[UseSmartestModel]` attributes are also available for automatic model selection. |
||||
|
|
||||
|
### Tools |
||||
|
|
||||
|
Implement the `HasTools` interface and scaffold tools with `php artisan make:tool`: |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Contracts\HasTools; |
||||
|
|
||||
|
class MyAgent implements Agent, HasTools |
||||
|
{ |
||||
|
use Promptable; |
||||
|
|
||||
|
public function tools(): iterable |
||||
|
{ |
||||
|
return [new MyCustomTool]; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Provider Tools |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Providers\Tools\{WebSearch, WebFetch, FileSearch}; |
||||
|
|
||||
|
public function tools(): iterable |
||||
|
{ |
||||
|
return [ |
||||
|
(new WebSearch)->max(5)->allow(['laravel.com']), |
||||
|
new WebFetch, |
||||
|
new FileSearch(stores: ['store_id']), |
||||
|
]; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Conversation Memory |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Concerns\RemembersConversations; |
||||
|
use Laravel\Ai\Contracts\Conversational; |
||||
|
|
||||
|
class ChatBot implements Agent, Conversational |
||||
|
{ |
||||
|
use Promptable, RemembersConversations; |
||||
|
// ... |
||||
|
} |
||||
|
|
||||
|
$response = (new ChatBot)->forUser($user)->prompt('Hello!'); |
||||
|
$response = (new ChatBot)->continue($conversationId, as: $user)->prompt('More...'); |
||||
|
``` |
||||
|
|
||||
|
### Failover |
||||
|
|
||||
|
```php |
||||
|
$response = (new MyAgent)->prompt('Hello', provider: [Lab::OpenAI, Lab::Anthropic]); |
||||
|
``` |
||||
|
|
||||
|
## Testing and Faking |
||||
|
|
||||
|
Each capability supports `fake()` with assertions: |
||||
|
|
||||
|
```php |
||||
|
use App\Ai\Agents\SalesCoach; |
||||
|
use Laravel\Ai\{Image, Audio, Transcription, Embeddings, Reranking, Files, Stores}; |
||||
|
|
||||
|
// Agents |
||||
|
SalesCoach::fake(['Response 1', 'Response 2']); |
||||
|
SalesCoach::assertPrompted('query'); |
||||
|
SalesCoach::assertNotPrompted('query'); |
||||
|
SalesCoach::assertNeverPrompted(); |
||||
|
SalesCoach::fake()->preventStrayPrompts(); |
||||
|
|
||||
|
// Images |
||||
|
Image::fake(); |
||||
|
Image::assertGenerated(fn ($prompt) => $prompt->contains('sunset')); |
||||
|
Image::assertNothingGenerated(); |
||||
|
|
||||
|
// Audio |
||||
|
Audio::fake(); |
||||
|
Audio::assertGenerated(fn ($prompt) => $prompt->contains('Hello')); |
||||
|
|
||||
|
// Transcription |
||||
|
Transcription::fake(['Transcribed text.']); |
||||
|
Transcription::assertGenerated(fn ($prompt) => $prompt->isDiarized()); |
||||
|
|
||||
|
// Embeddings |
||||
|
Embeddings::fake(); |
||||
|
Embeddings::assertGenerated(fn ($prompt) => $prompt->contains('Laravel')); |
||||
|
|
||||
|
// Reranking |
||||
|
Reranking::fake(); |
||||
|
Reranking::assertReranked(fn ($prompt) => $prompt->contains('PHP')); |
||||
|
|
||||
|
// Files |
||||
|
Files::fake(); |
||||
|
Files::assertStored(fn ($file) => $file->mimeType() === 'text/plain'); |
||||
|
|
||||
|
// Stores |
||||
|
Stores::fake(); |
||||
|
Stores::assertCreated('Knowledge Base'); |
||||
|
$store = Stores::get('id'); |
||||
|
$store->assertAdded('file_id'); |
||||
|
``` |
||||
|
|
||||
|
## Key Patterns |
||||
|
|
||||
|
- Namespace: `Laravel\Ai\` |
||||
|
- Package: `composer require laravel/ai` |
||||
|
- Agent pattern: Implement the `Agent` interface and use the `Promptable` trait |
||||
|
- Optional interfaces: `HasTools`, `HasMiddleware`, `HasStructuredOutput`, `Conversational` |
||||
|
- Entry-point classes: `Image`, `Audio`, `Transcription`, `Embeddings`, `Reranking`, `Stores` |
||||
|
- Provider enum: `Laravel\Ai\Enums\Lab` (prefer over plain strings) |
||||
|
- Artisan commands: `php artisan make:agent`, `php artisan make:tool` |
||||
|
- Global helper: `agent()` for anonymous agents |
||||
|
|
||||
|
## Common Pitfalls |
||||
|
|
||||
|
### Wrong Namespace |
||||
|
|
||||
|
The namespace is `Laravel\Ai`, not `Illuminate\Ai` or `Laravel\AI`. |
||||
|
|
||||
|
```php |
||||
|
// Correct |
||||
|
use Laravel\Ai\Image; |
||||
|
use Laravel\Ai\Contracts\Agent; |
||||
|
use Laravel\Ai\Promptable; |
||||
|
|
||||
|
// Wrong — these do not exist |
||||
|
use Illuminate\Ai\Image; |
||||
|
use Laravel\AI\Agent; |
||||
|
``` |
||||
|
|
||||
|
### Unsupported Provider Capability |
||||
|
|
||||
|
Calling a capability not supported by a provider throws a `LogicException`. Refer to the provider support table below. |
||||
|
|
||||
|
## Provider Support |
||||
|
|
||||
|
| Feature | Providers | |
||||
|
| ---------- | --------------------------------------------------------------- | |
||||
|
| Text | OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama | |
||||
|
| Images | OpenAI, Gemini, xAI | |
||||
|
| TTS | OpenAI, ElevenLabs | |
||||
|
| STT | OpenAI, ElevenLabs, Mistral | |
||||
|
| Embeddings | OpenAI, Gemini, Azure, Cohere, Mistral, Jina, VoyageAI | |
||||
|
| Reranking | Cohere, Jina | |
||||
|
| Files | OpenAI, Anthropic, Gemini | |
||||
|
|
||||
|
Use the `Laravel\Ai\Enums\Lab` enum to reference providers in code instead of plain strings: |
||||
|
|
||||
|
```php |
||||
|
use Laravel\Ai\Enums\Lab; |
||||
|
|
||||
|
Lab::Anthropic; |
||||
|
Lab::OpenAI; |
||||
|
Lab::Gemini; |
||||
|
// ... |
||||
|
``` |
||||
@ -0,0 +1,230 @@ |
|||||
|
--- |
||||
|
name: laravel-backup |
||||
|
description: "Configure and extend spatie/laravel-backup for database and file backups, cleanup strategies, health monitoring, and notifications. Activates when working with backup configuration, scheduling backups, creating custom cleanup strategies or health checks, customizing notifications, or when the user mentions backups, backup monitoring, backup cleanup, or spatie/laravel-backup." |
||||
|
license: MIT |
||||
|
metadata: |
||||
|
author: spatie |
||||
|
--- |
||||
|
|
||||
|
# Laravel Backup |
||||
|
|
||||
|
## When to Apply |
||||
|
|
||||
|
Activate this skill when: |
||||
|
|
||||
|
- Configuring backup sources, destinations, or notifications |
||||
|
- Scheduling backup, cleanup, or monitor commands |
||||
|
- Creating custom cleanup strategies or health checks |
||||
|
- Customizing backup notifications |
||||
|
- Troubleshooting backup failures |
||||
|
|
||||
|
## Key Commands |
||||
|
|
||||
|
```bash |
||||
|
|
||||
|
# Run a backup |
||||
|
|
||||
|
php artisan backup:run |
||||
|
|
||||
|
# Backup only the database |
||||
|
|
||||
|
php artisan backup:run --only-db |
||||
|
|
||||
|
# Backup specific database connections |
||||
|
|
||||
|
php artisan backup:run --db-name=mysql --db-name=pgsql |
||||
|
|
||||
|
# Backup only files (no database) |
||||
|
|
||||
|
php artisan backup:run --only-files |
||||
|
|
||||
|
# Backup to a specific disk |
||||
|
|
||||
|
php artisan backup:run --only-to-disk=s3 |
||||
|
|
||||
|
# Custom filename |
||||
|
|
||||
|
php artisan backup:run --filename=my-backup.zip |
||||
|
|
||||
|
# Clean old backups |
||||
|
|
||||
|
php artisan backup:clean |
||||
|
|
||||
|
# List all backups |
||||
|
|
||||
|
php artisan backup:list |
||||
|
|
||||
|
# Monitor backup health |
||||
|
|
||||
|
php artisan backup:monitor |
||||
|
|
||||
|
# Use an alternative config key |
||||
|
|
||||
|
php artisan backup:run --config=backup_secondary |
||||
|
``` |
||||
|
|
||||
|
## Scheduling |
||||
|
|
||||
|
Add to `routes/console.php` or `app/Console/Kernel.php`: |
||||
|
|
||||
|
```php |
||||
|
use Illuminate\Support\Facades\Schedule; |
||||
|
|
||||
|
Schedule::command('backup:clean')->daily()->at('01:00'); |
||||
|
Schedule::command('backup:run')->daily()->at('01:30'); |
||||
|
Schedule::command('backup:monitor')->daily()->at('10:00'); |
||||
|
``` |
||||
|
|
||||
|
## Configuration |
||||
|
|
||||
|
Published to `config/backup.php` with sections: `backup` (source files/databases, destination disks, encryption), `notifications` (mail, Slack, Discord), `monitor_backups` (health checks), and `cleanup` (retention strategy). |
||||
|
|
||||
|
### Database Dump Customization |
||||
|
|
||||
|
Customize dumps per connection in `config/database.php`: |
||||
|
|
||||
|
```php |
||||
|
'mysql' => [ |
||||
|
// ... |
||||
|
'dump' => [ |
||||
|
'exclude_tables' => ['telescope_entries', 'telescope_monitoring'], |
||||
|
'useSingleTransaction' => true, |
||||
|
], |
||||
|
], |
||||
|
``` |
||||
|
|
||||
|
Enable dump compression: |
||||
|
|
||||
|
```php |
||||
|
// config/backup.php |
||||
|
'database_dump_compressor' => \Spatie\DbDumper\Compressors\GzipCompressor::class, |
||||
|
``` |
||||
|
|
||||
|
### Multiple Backup Destinations |
||||
|
|
||||
|
```php |
||||
|
// config/backup.php |
||||
|
'destination' => [ |
||||
|
'disks' => ['local', 's3'], |
||||
|
], |
||||
|
``` |
||||
|
|
||||
|
### Encryption |
||||
|
|
||||
|
```php |
||||
|
// config/backup.php |
||||
|
'password' => env('BACKUP_ARCHIVE_PASSWORD'), |
||||
|
'encryption' => 'default', // Uses ZipArchive::EM_AES_256 |
||||
|
``` |
||||
|
|
||||
|
## Custom Cleanup Strategy |
||||
|
|
||||
|
Extend `Spatie\Backup\Tasks\Cleanup\CleanupStrategy` and implement `deleteOldBackups`: |
||||
|
|
||||
|
```php |
||||
|
use Spatie\Backup\BackupDestination\BackupCollection; |
||||
|
use Spatie\Backup\Tasks\Cleanup\CleanupStrategy; |
||||
|
|
||||
|
class MyCleanupStrategy extends CleanupStrategy |
||||
|
{ |
||||
|
public function deleteOldBackups(BackupCollection $backups): void |
||||
|
{ |
||||
|
$backups |
||||
|
->reject(fn ($backup) => $backup->date()->gt(now()->subMonth())) |
||||
|
->each(fn ($backup) => $backup->delete()); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Register in `config/backup.php`: |
||||
|
|
||||
|
```php |
||||
|
'cleanup' => [ |
||||
|
'strategy' => \App\Backup\MyCleanupStrategy::class, |
||||
|
], |
||||
|
``` |
||||
|
|
||||
|
## Custom Health Check |
||||
|
|
||||
|
Extend `Spatie\Backup\Tasks\Monitor\HealthCheck` and implement `checkHealth`: |
||||
|
|
||||
|
```php |
||||
|
use Spatie\Backup\BackupDestination\BackupDestination; |
||||
|
use Spatie\Backup\Tasks\Monitor\HealthCheck; |
||||
|
|
||||
|
class MinimumBackupCount extends HealthCheck |
||||
|
{ |
||||
|
protected int $minimumCount; |
||||
|
|
||||
|
public function __construct(int $minimumCount = 3) |
||||
|
{ |
||||
|
$this->minimumCount = $minimumCount; |
||||
|
} |
||||
|
|
||||
|
public function checkHealth(BackupDestination $backupDestination): void |
||||
|
{ |
||||
|
$this->failIf( |
||||
|
$backupDestination->backups()->count() < $this->minimumCount, |
||||
|
"Expected at least {$this->minimumCount} backups." |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Register in `config/backup.php`: |
||||
|
|
||||
|
```php |
||||
|
'health_checks' => [ |
||||
|
\App\Backup\MinimumBackupCount::class => 5, |
||||
|
], |
||||
|
``` |
||||
|
|
||||
|
## Custom Notification |
||||
|
|
||||
|
Extend `Spatie\Backup\Notifications\BaseNotification`: |
||||
|
|
||||
|
```php |
||||
|
use Illuminate\Notifications\Messages\MailMessage; |
||||
|
use Spatie\Backup\Notifications\BaseNotification; |
||||
|
|
||||
|
class CustomBackupFailedNotification extends BaseNotification |
||||
|
{ |
||||
|
public function toMail(): MailMessage |
||||
|
{ |
||||
|
return (new MailMessage) |
||||
|
->error() |
||||
|
->subject("Backup failed for {$this->applicationName()}") |
||||
|
->line($this->event->exception->getMessage()); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Map it in `config/backup.php`: |
||||
|
|
||||
|
```php |
||||
|
'notifications' => [ |
||||
|
'notifications' => [ |
||||
|
\App\Notifications\CustomBackupFailedNotification::class => ['mail'], |
||||
|
], |
||||
|
], |
||||
|
``` |
||||
|
|
||||
|
## Events |
||||
|
|
||||
|
All events are in `Spatie\Backup\Events`: |
||||
|
|
||||
|
- `BackupWasSuccessful` - Backup completed successfully |
||||
|
- `BackupHasFailed` - Backup failed (includes exception and optional backup destination) |
||||
|
- `BackupManifestWasCreated` - File manifest created before zipping |
||||
|
- `BackupZipWasCreated` - Zip archive created (used for encryption hook) |
||||
|
- `DumpingDatabase` - Database dump in progress |
||||
|
- `CleanupWasSuccessful` / `CleanupHasFailed` - Cleanup result |
||||
|
- `HealthyBackupWasFound` / `UnhealthyBackupWasFound` - Monitor result |
||||
|
|
||||
|
## Common Pitfalls |
||||
|
|
||||
|
- Forgetting to schedule `backup:clean` alongside `backup:run`, causing disk space to fill up |
||||
|
- Not excluding `vendor` and `node_modules` from file backups (excluded by default) |
||||
|
- Setting `only-db` and `only-files` together (mutually exclusive) |
||||
|
- Missing the `ext-zip` PHP extension |
||||
|
- Not configuring the notification `mail.to` address after publishing config |
||||
@ -0,0 +1,190 @@ |
|||||
|
--- |
||||
|
name: laravel-best-practices |
||||
|
description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." |
||||
|
license: MIT |
||||
|
metadata: |
||||
|
author: laravel |
||||
|
--- |
||||
|
|
||||
|
# Laravel Best Practices |
||||
|
|
||||
|
Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`. |
||||
|
|
||||
|
## Consistency First |
||||
|
|
||||
|
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. |
||||
|
|
||||
|
Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. |
||||
|
|
||||
|
## Quick Reference |
||||
|
|
||||
|
### 1. Database Performance → `rules/db-performance.md` |
||||
|
|
||||
|
- Eager load with `with()` to prevent N+1 queries |
||||
|
- Enable `Model::preventLazyLoading()` in development |
||||
|
- Select only needed columns, avoid `SELECT *` |
||||
|
- `chunk()` / `chunkById()` for large datasets |
||||
|
- Index columns used in `WHERE`, `ORDER BY`, `JOIN` |
||||
|
- `withCount()` instead of loading relations to count |
||||
|
- `cursor()` for memory-efficient read-only iteration |
||||
|
- Never query in Blade templates |
||||
|
|
||||
|
### 2. Advanced Query Patterns → `rules/advanced-queries.md` |
||||
|
|
||||
|
- `addSelect()` subqueries over eager-loading entire has-many for a single value |
||||
|
- Dynamic relationships via subquery FK + `belongsTo` |
||||
|
- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries |
||||
|
- `setRelation()` to prevent circular N+1 queries |
||||
|
- `whereIn` + `pluck()` over `whereHas` for better index usage |
||||
|
- Two simple queries can beat one complex query |
||||
|
- Compound indexes matching `orderBy` column order |
||||
|
- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) |
||||
|
|
||||
|
### 3. Security → `rules/security.md` |
||||
|
|
||||
|
- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates |
||||
|
- No raw SQL with user input — use Eloquent or query builder |
||||
|
- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes |
||||
|
- Validate MIME type, extension, and size for file uploads |
||||
|
- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields |
||||
|
|
||||
|
### 4. Caching → `rules/caching.md` |
||||
|
|
||||
|
- `Cache::remember()` over manual get/put |
||||
|
- `Cache::flexible()` for stale-while-revalidate on high-traffic data |
||||
|
- `Cache::memo()` to avoid redundant cache hits within a request |
||||
|
- Cache tags to invalidate related groups |
||||
|
- `Cache::add()` for atomic conditional writes |
||||
|
- `once()` to memoize per-request or per-object lifetime |
||||
|
- `Cache::lock()` / `lockForUpdate()` for race conditions |
||||
|
- Failover cache stores in production |
||||
|
|
||||
|
### 5. Eloquent Patterns → `rules/eloquent.md` |
||||
|
|
||||
|
- Correct relationship types with return type hints |
||||
|
- Local scopes for reusable query constraints |
||||
|
- Global scopes sparingly — document their existence |
||||
|
- Attribute casts in the `casts()` method |
||||
|
- Cast date columns, use Carbon instances in templates |
||||
|
- `whereBelongsTo($model)` for cleaner queries |
||||
|
- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries |
||||
|
|
||||
|
### 6. Validation & Forms → `rules/validation.md` |
||||
|
|
||||
|
- Form Request classes, not inline validation |
||||
|
- Array notation `['required', 'email']` for new code; follow existing convention |
||||
|
- `$request->validated()` only — never `$request->all()` |
||||
|
- `Rule::when()` for conditional validation |
||||
|
- `after()` instead of `withValidator()` |
||||
|
|
||||
|
### 7. Configuration → `rules/config.md` |
||||
|
|
||||
|
- `env()` only inside config files |
||||
|
- `App::environment()` or `app()->isProduction()` |
||||
|
- Config, lang files, and constants over hardcoded text |
||||
|
|
||||
|
### 8. Testing Patterns → `rules/testing.md` |
||||
|
|
||||
|
- `LazilyRefreshDatabase` over `RefreshDatabase` for speed |
||||
|
- `assertModelExists()` over raw `assertDatabaseHas()` |
||||
|
- Factory states and sequences over manual overrides |
||||
|
- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before |
||||
|
- `recycle()` to share relationship instances across factories |
||||
|
|
||||
|
### 9. Queue & Job Patterns → `rules/queue-jobs.md` |
||||
|
|
||||
|
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` |
||||
|
- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release |
||||
|
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` |
||||
|
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs |
||||
|
- Horizon for complex multi-queue scenarios |
||||
|
|
||||
|
### 10. Routing & Controllers → `rules/routing.md` |
||||
|
|
||||
|
- Implicit route model binding |
||||
|
- Scoped bindings for nested resources |
||||
|
- `Route::resource()` or `apiResource()` |
||||
|
- Methods under 10 lines — extract to actions/services |
||||
|
- Type-hint Form Requests for auto-validation |
||||
|
|
||||
|
### 11. HTTP Client → `rules/http-client.md` |
||||
|
|
||||
|
- Explicit `timeout` and `connectTimeout` on every request |
||||
|
- `retry()` with exponential backoff for external APIs |
||||
|
- Check response status or use `throw()` |
||||
|
- `Http::pool()` for concurrent independent requests |
||||
|
- `Http::fake()` and `preventStrayRequests()` in tests |
||||
|
|
||||
|
### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` |
||||
|
|
||||
|
- Event discovery over manual registration; `event:cache` in production |
||||
|
- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions |
||||
|
- Queue notifications and mailables with `ShouldQueue` |
||||
|
- On-demand notifications for non-user recipients |
||||
|
- `HasLocalePreference` on notifiable models |
||||
|
- `assertQueued()` not `assertSent()` for queued mailables |
||||
|
- Markdown mailables for transactional emails |
||||
|
|
||||
|
### 13. Error Handling → `rules/error-handling.md` |
||||
|
|
||||
|
- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern |
||||
|
- `ShouldntReport` for exceptions that should never log |
||||
|
- Throttle high-volume exceptions to protect log sinks |
||||
|
- `dontReportDuplicates()` for multi-catch scenarios |
||||
|
- Force JSON rendering for API routes |
||||
|
- Structured context via `context()` on exception classes |
||||
|
|
||||
|
### 14. Task Scheduling → `rules/scheduling.md` |
||||
|
|
||||
|
- `withoutOverlapping()` on variable-duration tasks |
||||
|
- `onOneServer()` on multi-server deployments |
||||
|
- `runInBackground()` for concurrent long tasks |
||||
|
- `environments()` to restrict to appropriate environments |
||||
|
- `takeUntilTimeout()` for time-bounded processing |
||||
|
- Schedule groups for shared configuration |
||||
|
|
||||
|
### 15. Architecture → `rules/architecture.md` |
||||
|
|
||||
|
- Single-purpose Action classes; dependency injection over `app()` helper |
||||
|
- Prefer official Laravel packages and follow conventions, don't override defaults |
||||
|
- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety |
||||
|
- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution |
||||
|
|
||||
|
### 16. Migrations → `rules/migrations.md` |
||||
|
|
||||
|
- Generate migrations with `php artisan make:migration` |
||||
|
- `constrained()` for foreign keys |
||||
|
- Never modify migrations that have run in production |
||||
|
- Add indexes in the migration, not as an afterthought |
||||
|
- Mirror column defaults in model `$attributes` |
||||
|
- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes |
||||
|
- One concern per migration — never mix DDL and DML |
||||
|
|
||||
|
### 17. Collections → `rules/collections.md` |
||||
|
|
||||
|
- Higher-order messages for simple collection operations |
||||
|
- `cursor()` vs. `lazy()` — choose based on relationship needs |
||||
|
- `lazyById()` when updating records while iterating |
||||
|
- `toQuery()` for bulk operations on collections |
||||
|
|
||||
|
### 18. Blade & Views → `rules/blade-views.md` |
||||
|
|
||||
|
- `$attributes->merge()` in component templates |
||||
|
- Blade components over `@include`; `@pushOnce` for per-component scripts |
||||
|
- View Composers for shared view data |
||||
|
- `@aware` for deeply nested component props |
||||
|
|
||||
|
### 19. Conventions & Style → `rules/style.md` |
||||
|
|
||||
|
- Follow Laravel naming conventions for all entities |
||||
|
- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions |
||||
|
- No JS/CSS in Blade, no HTML in PHP classes |
||||
|
- Code should be readable; comments only for config files |
||||
|
|
||||
|
## How to Apply |
||||
|
|
||||
|
Always use a sub-agent to read rule files and explore this skill's content. |
||||
|
|
||||
|
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) |
||||
|
2. Check sibling files for existing patterns — follow those first per Consistency First |
||||
|
3. Verify API syntax with `search-docs` for the installed Laravel version |
||||
@ -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 execute(array $data): Order |
||||
|
{ |
||||
|
$order = Order::create($data); |
||||
|
$this->inventory->reserve($order); |
||||
|
|
||||
|
return $order; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Use Dependency Injection |
||||
|
|
||||
|
Always use constructor injection. Avoid `app()` or `resolve()` inside classes. |
||||
|
|
||||
|
Incorrect: |
||||
|
```php |
||||
|
class OrderController extends Controller |
||||
|
{ |
||||
|
public function store(StoreOrderRequest $request) |
||||
|
{ |
||||
|
$service = app(OrderService::class); |
||||
|
|
||||
|
return $service->create($request->validated()); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Correct: |
||||
|
```php |
||||
|
class OrderController extends Controller |
||||
|
{ |
||||
|
public function __construct(private OrderService $service) {} |
||||
|
|
||||
|
public function store(StoreOrderRequest $request) |
||||
|
{ |
||||
|
return $this->service->create($request->validated()); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Code to Interfaces |
||||
|
|
||||
|
Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability. |
||||
|
|
||||
|
Incorrect (concrete dependency): |
||||
|
```php |
||||
|
class OrderService |
||||
|
{ |
||||
|
public function __construct(private StripeGateway $gateway) {} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Correct (interface dependency): |
||||
|
```php |
||||
|
interface PaymentGateway |
||||
|
{ |
||||
|
public function charge(int $amount, string $customerId): PaymentResult; |
||||
|
} |
||||
|
|
||||
|
class OrderService |
||||
|
{ |
||||
|
public function __construct(private PaymentGateway $gateway) {} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Bind in a service provider: |
||||
|
|
||||
|
```php |
||||
|
$this->app->bind(PaymentGateway::class, StripeGateway::class); |
||||
|
``` |
||||
|
|
||||
|
## Default Sort by Descending |
||||
|
|
||||
|
When no explicit order is specified, sort by `id` or `created_at` descending. 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,148 @@ |
|||||
|
# Eloquent Best Practices |
||||
|
|
||||
|
## Use Correct Relationship Types |
||||
|
|
||||
|
Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints. |
||||
|
|
||||
|
```php |
||||
|
public function comments(): HasMany |
||||
|
{ |
||||
|
return $this->hasMany(Comment::class); |
||||
|
} |
||||
|
|
||||
|
public function author(): BelongsTo |
||||
|
{ |
||||
|
return $this->belongsTo(User::class, 'user_id'); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Use Local Scopes for Reusable Queries |
||||
|
|
||||
|
Extract reusable query constraints into local scopes to avoid duplication. |
||||
|
|
||||
|
Incorrect: |
||||
|
```php |
||||
|
$active = User::where('verified', true)->whereNotNull('activated_at')->get(); |
||||
|
$articles = Article::whereHas('user', function ($q) { |
||||
|
$q->where('verified', true)->whereNotNull('activated_at'); |
||||
|
})->get(); |
||||
|
``` |
||||
|
|
||||
|
Correct: |
||||
|
```php |
||||
|
public function scopeActive(Builder $query): Builder |
||||
|
{ |
||||
|
return $query->where('verified', true)->whereNotNull('activated_at'); |
||||
|
} |
||||
|
|
||||
|
// Usage |
||||
|
$active = User::active()->get(); |
||||
|
$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); |
||||
|
``` |
||||
|
|
||||
|
## Apply Global Scopes Sparingly |
||||
|
|
||||
|
Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. |
||||
|
|
||||
|
Incorrect (global scope for a conditional filter): |
||||
|
```php |
||||
|
class PublishedScope implements Scope |
||||
|
{ |
||||
|
public function apply(Builder $builder, Model $model): void |
||||
|
{ |
||||
|
$builder->where('published', true); |
||||
|
} |
||||
|
} |
||||
|
// Now admin panels, reports, and background jobs all silently skip drafts |
||||
|
``` |
||||
|
|
||||
|
Correct (local scope you opt into): |
||||
|
```php |
||||
|
public function scopePublished(Builder $query): Builder |
||||
|
{ |
||||
|
return $query->where('published', true); |
||||
|
} |
||||
|
|
||||
|
Post::published()->paginate(); // Explicit |
||||
|
Post::paginate(); // Admin sees all |
||||
|
``` |
||||
|
|
||||
|
## Define Attribute Casts |
||||
|
|
||||
|
Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. |
||||
|
|
||||
|
```php |
||||
|
protected function casts(): array |
||||
|
{ |
||||
|
return [ |
||||
|
'is_active' => 'boolean', |
||||
|
'metadata' => 'array', |
||||
|
'total' => 'decimal:2', |
||||
|
]; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Cast Date Columns Properly |
||||
|
|
||||
|
Always cast date columns. Use Carbon instances in templates instead of formatting strings manually. |
||||
|
|
||||
|
Incorrect: |
||||
|
```blade |
||||
|
{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }} |
||||
|
``` |
||||
|
|
||||
|
Correct: |
||||
|
```php |
||||
|
protected function casts(): array |
||||
|
{ |
||||
|
return [ |
||||
|
'ordered_at' => 'datetime', |
||||
|
]; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
```blade |
||||
|
{{ $order->ordered_at->toDateString() }} |
||||
|
{{ $order->ordered_at->format('m-d') }} |
||||
|
``` |
||||
|
|
||||
|
## Use `whereBelongsTo()` for Relationship Queries |
||||
|
|
||||
|
Cleaner than manually specifying foreign keys. |
||||
|
|
||||
|
Incorrect: |
||||
|
```php |
||||
|
Post::where('user_id', $user->id)->get(); |
||||
|
``` |
||||
|
|
||||
|
Correct: |
||||
|
```php |
||||
|
Post::whereBelongsTo($user)->get(); |
||||
|
Post::whereBelongsTo($user, 'author')->get(); |
||||
|
``` |
||||
|
|
||||
|
## Avoid Hardcoded Table Names in Queries |
||||
|
|
||||
|
Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string). |
||||
|
|
||||
|
Incorrect: |
||||
|
```php |
||||
|
DB::table('users')->where('active', true)->get(); |
||||
|
|
||||
|
$query->join('companies', 'companies.id', '=', 'users.company_id'); |
||||
|
|
||||
|
DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); |
||||
|
``` |
||||
|
|
||||
|
Correct — reference the model's table: |
||||
|
```php |
||||
|
DB::table((new User)->getTable())->where('active', true)->get(); |
||||
|
|
||||
|
// Even better — use Eloquent or the query builder instead of raw SQL |
||||
|
User::where('active', true)->get(); |
||||
|
Order::where('status', 'pending')->get(); |
||||
|
``` |
||||
|
|
||||
|
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable. |
||||
|
|
||||
|
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration. |
||||
@ -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. In Inertia apps, the `@csrf` directive is automatically applied. |
||||
|
|
||||
|
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.'); |
||||
|
} |
||||
|
}, |
||||
|
]; |
||||
|
} |
||||
|
``` |
||||
@ -0,0 +1,277 @@ |
|||||
|
--- |
||||
|
name: laravel-permission-development |
||||
|
description: Build and work with Spatie Laravel Permission features, including roles, permissions, middleware, policies, teams, and Blade directives. |
||||
|
--- |
||||
|
|
||||
|
# Laravel Permission Development |
||||
|
|
||||
|
## When to use this skill |
||||
|
|
||||
|
Use this skill when working with authorization, roles, permissions, access control, middleware guards, or Blade permission directives using spatie/laravel-permission. |
||||
|
|
||||
|
## Core Concepts |
||||
|
|
||||
|
- **Users have Roles, Roles have Permissions, Apps check Permissions** (not Roles). |
||||
|
- Direct permissions on users are an anti-pattern; assign permissions to roles instead. |
||||
|
- Use `$user->can('permission-name')` for all authorization checks (supports Super Admin via Gate). |
||||
|
- The `HasRoles` trait (which includes `HasPermissions`) is added to User models. |
||||
|
|
||||
|
## Setup |
||||
|
|
||||
|
Add the `HasRoles` trait to your User model: |
||||
|
|
||||
|
```php |
||||
|
use Spatie\Permission\Traits\HasRoles; |
||||
|
|
||||
|
class User extends Authenticatable |
||||
|
{ |
||||
|
use HasRoles; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Creating Roles and Permissions |
||||
|
|
||||
|
```php |
||||
|
use Spatie\Permission\Models\Role; |
||||
|
use Spatie\Permission\Models\Permission; |
||||
|
|
||||
|
$role = Role::create(['name' => 'writer']); |
||||
|
$permission = Permission::create(['name' => 'edit articles']); |
||||
|
|
||||
|
// findOrCreate is idempotent (safe for seeders) |
||||
|
$role = Role::findOrCreate('writer', 'web'); |
||||
|
$permission = Permission::findOrCreate('edit articles', 'web'); |
||||
|
``` |
||||
|
|
||||
|
## Assigning Roles and Permissions |
||||
|
|
||||
|
```php |
||||
|
// Assign roles to users |
||||
|
$user->assignRole('writer'); |
||||
|
$user->assignRole('writer', 'admin'); |
||||
|
$user->assignRole(['writer', 'admin']); |
||||
|
$user->syncRoles(['writer', 'admin']); // replaces all |
||||
|
$user->removeRole('writer'); |
||||
|
|
||||
|
// Assign permissions to roles (preferred) |
||||
|
$role->givePermissionTo('edit articles'); |
||||
|
$role->givePermissionTo(['edit articles', 'delete articles']); |
||||
|
$role->syncPermissions(['edit articles', 'delete articles']); |
||||
|
$role->revokePermissionTo('edit articles'); |
||||
|
|
||||
|
// Reverse assignment |
||||
|
$permission->assignRole('writer'); |
||||
|
$permission->syncRoles(['writer', 'editor']); |
||||
|
$permission->removeRole('writer'); |
||||
|
``` |
||||
|
|
||||
|
## Checking Roles and Permissions |
||||
|
|
||||
|
```php |
||||
|
// Permission checks (preferred - supports Super Admin via Gate) |
||||
|
$user->can('edit articles'); |
||||
|
$user->canAny(['edit articles', 'delete articles']); |
||||
|
|
||||
|
// Direct package methods (bypass Gate, no Super Admin support) |
||||
|
$user->hasPermissionTo('edit articles'); |
||||
|
$user->hasAnyPermission(['edit articles', 'publish articles']); |
||||
|
$user->hasAllPermissions(['edit articles', 'publish articles']); |
||||
|
$user->hasDirectPermission('edit articles'); |
||||
|
|
||||
|
// Role checks |
||||
|
$user->hasRole('writer'); |
||||
|
$user->hasAnyRole(['writer', 'editor']); |
||||
|
$user->hasAllRoles(['writer', 'editor']); |
||||
|
$user->hasExactRoles(['writer', 'editor']); |
||||
|
|
||||
|
// Get assigned roles and permissions |
||||
|
$user->getRoleNames(); // Collection of role name strings |
||||
|
$user->getPermissionNames(); // Collection of permission name strings |
||||
|
$user->getDirectPermissions(); // Direct permissions only |
||||
|
$user->getPermissionsViaRoles(); // Inherited via roles |
||||
|
$user->getAllPermissions(); // Both direct and inherited |
||||
|
``` |
||||
|
|
||||
|
## Query Scopes |
||||
|
|
||||
|
```php |
||||
|
$users = User::role('writer')->get(); |
||||
|
$users = User::withoutRole('writer')->get(); |
||||
|
$users = User::permission('edit articles')->get(); |
||||
|
$users = User::withoutPermission('edit articles')->get(); |
||||
|
``` |
||||
|
|
||||
|
## Middleware |
||||
|
|
||||
|
Register middleware aliases in `bootstrap/app.php`: |
||||
|
|
||||
|
```php |
||||
|
->withMiddleware(function (Middleware $middleware) { |
||||
|
$middleware->alias([ |
||||
|
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, |
||||
|
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, |
||||
|
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, |
||||
|
]); |
||||
|
}) |
||||
|
``` |
||||
|
|
||||
|
Use in routes (pipe `|` for OR logic): |
||||
|
|
||||
|
```php |
||||
|
Route::middleware(['permission:edit articles'])->group(function () { ... }); |
||||
|
Route::middleware(['role:manager|writer'])->group(function () { ... }); |
||||
|
Route::middleware(['role_or_permission:manager|edit articles'])->group(function () { ... }); |
||||
|
|
||||
|
// With specific guard |
||||
|
Route::middleware(['role:manager,api'])->group(function () { ... }); |
||||
|
``` |
||||
|
|
||||
|
For single permissions, Laravel's built-in `can` middleware also works: |
||||
|
|
||||
|
```php |
||||
|
Route::middleware(['can:edit articles'])->group(function () { ... }); |
||||
|
``` |
||||
|
|
||||
|
## Blade Directives |
||||
|
|
||||
|
Prefer `@can` (permission-based) over `@role` (role-based): |
||||
|
|
||||
|
```blade |
||||
|
@can('edit articles') |
||||
|
{{-- User can edit articles (supports Super Admin) --}} |
||||
|
@endcan |
||||
|
|
||||
|
@canany(['edit articles', 'delete articles']) |
||||
|
{{-- User can do at least one --}} |
||||
|
@endcanany |
||||
|
|
||||
|
@role('admin') |
||||
|
{{-- Only use for super-admin type checks --}} |
||||
|
@endrole |
||||
|
|
||||
|
@hasanyrole('writer|admin') |
||||
|
{{-- Has writer or admin --}} |
||||
|
@endhasanyrole |
||||
|
``` |
||||
|
|
||||
|
## Super Admin |
||||
|
|
||||
|
Use `Gate::before` in `AppServiceProvider::boot()`: |
||||
|
|
||||
|
```php |
||||
|
use Illuminate\Support\Facades\Gate; |
||||
|
|
||||
|
public function boot(): void |
||||
|
{ |
||||
|
Gate::before(function ($user, $ability) { |
||||
|
return $user->hasRole('Super Admin') ? true : null; |
||||
|
}); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
This makes `$user->can()` and `@can` always return true for Super Admins. Must return `null` (not `false`) to allow normal checks for other users. |
||||
|
|
||||
|
## Policies |
||||
|
|
||||
|
Use `$user->can()` inside policy methods to check permissions: |
||||
|
|
||||
|
```php |
||||
|
class PostPolicy |
||||
|
{ |
||||
|
public function update(User $user, Post $post): bool |
||||
|
{ |
||||
|
if ($user->can('edit all posts')) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
return $user->can('edit own posts') && $user->id === $post->user_id; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Enums |
||||
|
|
||||
|
```php |
||||
|
enum RolesEnum: string |
||||
|
{ |
||||
|
case WRITER = 'writer'; |
||||
|
case EDITOR = 'editor'; |
||||
|
} |
||||
|
|
||||
|
enum PermissionsEnum: string |
||||
|
{ |
||||
|
case EDIT_POSTS = 'edit posts'; |
||||
|
case DELETE_POSTS = 'delete posts'; |
||||
|
} |
||||
|
|
||||
|
// Creation requires ->value |
||||
|
Permission::findOrCreate(PermissionsEnum::EDIT_POSTS->value, 'web'); |
||||
|
|
||||
|
// Most methods accept enums directly |
||||
|
$user->assignRole(RolesEnum::WRITER); |
||||
|
$user->hasRole(RolesEnum::WRITER); |
||||
|
$role->givePermissionTo(PermissionsEnum::EDIT_POSTS); |
||||
|
$user->hasPermissionTo(PermissionsEnum::EDIT_POSTS); |
||||
|
``` |
||||
|
|
||||
|
## Seeding |
||||
|
|
||||
|
Always flush the permission cache when seeding: |
||||
|
|
||||
|
```php |
||||
|
class RolesAndPermissionsSeeder extends Seeder |
||||
|
{ |
||||
|
public function run(): void |
||||
|
{ |
||||
|
// Reset cache |
||||
|
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); |
||||
|
|
||||
|
// Create permissions |
||||
|
Permission::findOrCreate('edit articles', 'web'); |
||||
|
Permission::findOrCreate('delete articles', 'web'); |
||||
|
|
||||
|
// Create roles and assign permissions |
||||
|
Role::findOrCreate('writer', 'web') |
||||
|
->givePermissionTo(['edit articles']); |
||||
|
|
||||
|
Role::findOrCreate('admin', 'web') |
||||
|
->givePermissionTo(Permission::all()); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Teams (Multi-Tenancy) |
||||
|
|
||||
|
Enable in `config/permission.php` before running migrations: |
||||
|
|
||||
|
```php |
||||
|
'teams' => true, |
||||
|
``` |
||||
|
|
||||
|
Set the active team in middleware: |
||||
|
|
||||
|
```php |
||||
|
setPermissionsTeamId($teamId); |
||||
|
``` |
||||
|
|
||||
|
When switching teams, unset cached relations: |
||||
|
|
||||
|
```php |
||||
|
$user->unsetRelation('roles')->unsetRelation('permissions'); |
||||
|
``` |
||||
|
|
||||
|
## Events |
||||
|
|
||||
|
Enable in `config/permission.php`: |
||||
|
|
||||
|
```php |
||||
|
'events_enabled' => true, |
||||
|
``` |
||||
|
|
||||
|
Available events: `RoleAttachedEvent`, `RoleDetachedEvent`, `PermissionAttachedEvent`, `PermissionDetachedEvent` in the `Spatie\Permission\Events` namespace. |
||||
|
|
||||
|
## Performance |
||||
|
|
||||
|
- Permissions are cached automatically. The cache is flushed when roles/permissions change via package methods. |
||||
|
- After direct DB operations, flush manually: `app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions()` |
||||
|
- For bulk seeding, use `Permission::insert()` for speed, but flush the cache afterward. |
||||
@ -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 @@ |
|||||
|
# $schema: https://github.com/AJenbo/phpantom_lsp/raw/main/config-schema.json |
||||
@ -0,0 +1,601 @@ |
|||||
|
# Arsitektur Antigravity: Laravel Modular Monolith |
||||
|
|
||||
|
[](https://laravel.com) |
||||
|
[](https://www.php.net) |
||||
|
[](https://vitejs.dev) |
||||
|
[](https://pestphp.com) |
||||
|
|
||||
|
Selamat datang di repositori ini. Aplikasi ini dibangun menggunakan arsitektur **Pragmatic Domain-Driven Design (DDD)** yang ketat. Kami menyebutnya sebagai arsitektur "Antigravity" karena ia mencegah basis kode menjadi terlalu kompleks saat aplikasi berkembang pesat (scaling). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. Stack Teknis |
||||
|
|
||||
|
- **Backend:** PHP 8.4+ & Laravel 13.0 |
||||
|
- **Frontend:** Vite, AlpineJS, Livewire, Tailwind CSS |
||||
|
- **Database:** SQLite (default), MySQL, atau PostgreSQL |
||||
|
- **Testing:** Pest PHP |
||||
|
- **Package Managers:** Composer (PHP), NPM (JS) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. Persyaratan |
||||
|
|
||||
|
- **PHP:** ^8.4 |
||||
|
- **Node.js:** Disarankan LTS terbaru |
||||
|
- **Composer:** ^2.0 |
||||
|
- **Ekstensi:** `ext-zip`, `ext-pdo_sqlite` (jika menggunakan SQLite) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. Setup & Instalasi |
||||
|
|
||||
|
Proyek ini menyediakan skrip setup terpadu di `composer.json`. |
||||
|
|
||||
|
```bash |
||||
|
# 1. Clone repositori |
||||
|
git clone <repo-url> |
||||
|
cd laravel-base |
||||
|
|
||||
|
# 2. Jalankan setup otomatis |
||||
|
# Ini akan menginstal dependensi PHP/JS, membuat file .env, generate key, dan menjalankan migrasi |
||||
|
composer setup |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. Menjalankan Aplikasi |
||||
|
|
||||
|
Gunakan perintah pengembangan untuk menjalankan server, listener antrean, log, dan Vite secara bersamaan: |
||||
|
|
||||
|
```bash |
||||
|
composer dev |
||||
|
``` |
||||
|
|
||||
|
Aplikasi akan tersedia di `http://localhost:8000`. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. Skrip & Perintah |
||||
|
|
||||
|
Tersedia via Composer: |
||||
|
- `composer setup`: Bootstrap awal proyek. |
||||
|
- `composer dev`: Memulai lingkungan pengembangan (Server + Queue + Logs + Vite). |
||||
|
- `composer test`: Menjalankan suite pengujian (test suite). |
||||
|
- `php artisan domain:make`: Generator kustom untuk arsitektur DDD (lihat Bagian 12). |
||||
|
|
||||
|
Tersedia via NPM: |
||||
|
- `npm run dev`: Memulai dev server Vite. |
||||
|
- `npm run build`: Build aset untuk produksi. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6. Struktur Proyek |
||||
|
|
||||
|
```text |
||||
|
. |
||||
|
├── app/ |
||||
|
│ ├── Attributes/ <-- Atribut PHP 8 (SEO, Layout) |
||||
|
│ ├── Domains/ <-- Logika Bisnis Inti (The Vault) |
||||
|
│ ├── Http/ <-- Gateway Web/API (Controllers, Requests, DataTables) |
||||
|
│ ├── Livewire/ <-- Komponen & Form Livewire |
||||
|
│ ├── Providers/ <-- Service Providers |
||||
|
│ └── UI/ <-- Logika spesifik UI (Actions, Enums) |
||||
|
├── bootstrap/ <-- Logika bootstrap aplikasi |
||||
|
├── config/ <-- File konfigurasi Laravel |
||||
|
├── database/ <-- Migrasi, factory, dan seeder |
||||
|
├── public/ <-- Entry point server web (index.php) dan aset statis |
||||
|
├── resources/ |
||||
|
│ ├── lang/ <-- File lokalisasi |
||||
|
│ ├── views/ <-- Template Blade & komponen Livewire |
||||
|
│ └── css/js/ <-- Aset sumber frontend |
||||
|
├── routes/ <-- Rute Web, API, dan Konsol |
||||
|
├── tests/ <-- Pest test suite |
||||
|
├── storage/ <-- Log, unggahan file, dan cache |
||||
|
└── vite.config.js <-- Konfigurasi Vite |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7. Filosofi Inti |
||||
|
|
||||
|
Arsitektur ini memberlakukan batasan fisik yang keras antara **Delivery** (bagaimana pengguna berinteraksi dengan aplikasi) dan **Business Logic** (apa yang sebenarnya dilakukan aplikasi). |
||||
|
|
||||
|
* **The Gateway (Lapisan HTTP):** Controllers, komponen Livewire, dan Volt murni bertindak sebagai pengatur lalu lintas. Mereka menangani sesi web, cookie, pengalihan (redirect), dan validasi form. |
||||
|
* **The Domain (The Vault):** Actions, DTOs, dan Models di dalam `app/Domains/` menangani aturan bisnis yang sebenarnya, mutasi database, dan pemanggilan API eksternal. |
||||
|
|
||||
|
> **Aturan Emas:** Domain harus benar-benar terisolasi dari lapisan web. Anda tidak diperbolehkan menggunakan helper `request()`, `session()`, atau melempar eksepsi HTTP di dalam direktori `app/Domains/`. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 8. Struktur Direktori |
||||
|
|
||||
|
Aplikasi ini dibagi berdasarkan **Konsep Bisnis**, bukan fitur teknis. |
||||
|
|
||||
|
```text |
||||
|
app/ |
||||
|
├── Attributes/ <-- Atribut PHP 8 (misal, #[Seo], #[LayoutData]) |
||||
|
├── Console/ |
||||
|
│ ├── Commands/ <-- Perintah Artisan kustom (DomainMakeCommand, CleanOrphanedFiles) |
||||
|
│ └── stubs/ <-- Stub pembuatan kode kustom |
||||
|
├── Http/ <-- The Gateway (Lapisan HTTP) |
||||
|
│ ├── Controllers/ |
||||
|
│ │ ├── Api/ |
||||
|
│ │ │ └── V1/ <-- Kontroler API berversi |
||||
|
│ │ └── Web/ |
||||
|
│ │ ├── Auth/ <-- Kontroler autentikasi |
||||
|
│ │ ├── Identity/ <-- Kontroler manajemen pengguna & peran |
||||
|
│ │ └── Account/ <-- Kontroler manajemen profil |
||||
|
│ ├── DataTables/ <-- Konfigurasi DataTable Livewire |
||||
|
│ ├── Ingestion/ <-- Kelas Impor/Ingesti Excel |
||||
|
│ ├── Middleware/ <-- HandlePreferredLanguage, HandleSeoSetting, dll. |
||||
|
│ ├── Requests/ |
||||
|
│ │ ├── Api/ <-- Form request untuk API |
||||
|
│ │ └── Web/ <-- Form request untuk Web |
||||
|
│ └── Resources/ <-- Resource API (LookupResource, SuccessResource, dll.) |
||||
|
├── Livewire/ |
||||
|
│ ├── Concerns/ <-- Trait Livewire yang digunakan bersama (WithModal, WithToast) |
||||
|
│ └── Forms/ <-- Objek Form Livewire |
||||
|
├── Providers/ <-- AppServiceProvider, EventServiceProvider, UiServiceProvider |
||||
|
├── UI/ |
||||
|
│ ├── Actions/ <-- Actions di lapisan UI (SetSeoMetadata, ApplyLayoutMetadata) |
||||
|
│ ├── Enums/ <-- Enum spesifik UI (FileType, InputType) |
||||
|
│ └── Support/ <-- Kelas pembantu UI (LayoutState, StyledExport) |
||||
|
└── Domains/ |
||||
|
├── Identity/ <-- Konsep Bisnis: Autentikasi & Pengguna |
||||
|
│ ├── Actions/ <-- Mutasi yang dikelompokkan berdasarkan kapabilitas |
||||
|
│ ├── DTOs/ <-- Data Transfer Objects yang dikelompokkan berdasarkan kapabilitas |
||||
|
│ ├── Enums/ |
||||
|
│ ├── Events/ <-- Fakta masa lalu |
||||
|
│ ├── Exports/ |
||||
|
│ ├── Integration/ <-- Mapper sistem eksternal |
||||
|
│ ├── Listeners/ <-- Handler kata kerja aktif |
||||
|
│ ├── Models/ <-- User, Role, Permission |
||||
|
│ ├── Notifications/ |
||||
|
│ ├── Policies/ |
||||
|
│ ├── Providers/ |
||||
|
│ ├── Queries/ <-- Bacaan Kompleks |
||||
|
│ └── Scopes/ <-- Global Scopes Eloquent |
||||
|
├── Account/ <-- Konsep Bisnis: Profil & Tagihan |
||||
|
│ ├── Actions/ |
||||
|
│ ├── DTOs/ |
||||
|
│ ├── Enums/ |
||||
|
│ ├── Listeners/ |
||||
|
│ ├── Models/ |
||||
|
│ └── Providers/ |
||||
|
└── System/ <-- Konsep Bisnis: Infrastruktur Lintas Domain |
||||
|
├── Actions/ |
||||
|
├── Casts/ <-- Casts Eloquent kustom |
||||
|
├── DTOs/ |
||||
|
├── Enums/ |
||||
|
├── Events/ |
||||
|
├── Helpers/ <-- Helper spesifik domain (asset.php) |
||||
|
├── Jobs/ <-- Background job spesifik domain |
||||
|
├── Listeners/ |
||||
|
├── Mail/ <-- Mailable spesifik domain |
||||
|
├── Models/ <-- File, SystemSettings, Backup |
||||
|
├── Policies/ |
||||
|
├── Providers/ <-- SystemServiceProvider |
||||
|
├── Queries/ <-- GetSystemSettings, GetModelAuditLog |
||||
|
├── Support/ |
||||
|
├── Traits/ <-- Trait spesifik domain (HasFile) |
||||
|
└── ... |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 9. Aturan Pelaksanaan (Rules of Engagement) |
||||
|
|
||||
|
### DTOs (Data Transfer Objects) |
||||
|
|
||||
|
Semua data yang tidak dapat dipercaya dari Gateway harus dikemas ke dalam DTO *readonly* dengan pengetikan yang ketat (strictly typed) sebelum memasuki Domain. |
||||
|
|
||||
|
* DTO hanya berisi data yang ditujukan untuk mengubah state. |
||||
|
* Jangan masukkan model Eloquent ke dalam DTO. Lewatkan Model sebagai parameter terpisah ke Action. |
||||
|
|
||||
|
### Actions (Sang Eksekutor) |
||||
|
|
||||
|
Actions adalah satu-satunya tempat di mana mutasi database (`create`, `update`, `delete`, `syncRoles`) diizinkan. |
||||
|
|
||||
|
* Actions harus memiliki tanggung jawab tunggal (single responsibility). |
||||
|
* Gunakan `DB::transaction()` di dalam Actions saat beberapa penulisan database (misalnya, membuat pengguna dan menetapkan peran Spatie) harus berhasil atau gagal secara bersamaan. |
||||
|
* Gunakan **Action Composition** (menyuntikkan satu Action ke Action lainnya melalui konstruktor) untuk menggunakan kembali logika tanpa menduplikasi kode. |
||||
|
|
||||
|
### Events & Listeners |
||||
|
|
||||
|
Gunakan Arsitektur Event-Driven untuk semua efek samping (side effects) seperti email, logging, dan pemrosesan latar belakang (background processing). |
||||
|
|
||||
|
* Gateway mendispatch Events untuk fakta sesi yang tidak memutasi (misalnya, `UserLoggedIn`). |
||||
|
* Actions mendispatch Events segera setelah mutasi status berhasil (misalnya, `UserWasProvisioned`). |
||||
|
* Listeners menangani reaksi di luar siklus hidup HTTP utama dan merupakan **satu-satunya** tempat di mana pemanggilan `Notification::send()`, `Mail::send()`, atau logging dilakukan sebagai respons terhadap Event Domain. |
||||
|
* Events dan Listeners harus **dikelompokkan di bawah folder kapabilitas yang sama** dengan Action yang mendispatch mereka (misalnya, `Events/Onboarding/`, `Listeners/Onboarding/`). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 10. Konvensi Penamaan |
||||
|
|
||||
|
Arsitektur ini menggunakan bahasa penamaan yang ketat dan disengaja. Setiap nama harus mengomunikasikan **Niat Bisnis (Business Intent)**, bukan operasi database. |
||||
|
|
||||
|
### 10.1 Folder Domain (`app/Domains/{Name}/`) |
||||
|
|
||||
|
Nama domain adalah **Konsep Bisnis**, bukan lapisan teknis. Harus berupa kata benda tunggal yang mendeskripsikan *bounded context*. |
||||
|
|
||||
|
| ✅ Benar | ❌ Salah | Alasan | |
||||
|
|---|---|---| |
||||
|
| `Identity` | `Users` | Identity mencakup autentikasi, peran, dan siklus hidup pengguna — bukan sekadar nama tabel. | |
||||
|
| `Account` | `Profile` | Account memiliki seluruh permukaan akun pengguna, bukan hanya satu model. | |
||||
|
| `System` | `Utils` / `Helpers` | System adalah konteks bisnis nyata untuk infrastruktur lintas-potong (cross-cutting). | |
||||
|
|
||||
|
### 10.2 Folder Kapabilitas (Subdirektori Action / DTO / Event / Listener) |
||||
|
|
||||
|
Subdirektori di dalam `Actions/`, `DTOs/`, `Events/`, and `Listeners/` harus dinamai berdasarkan **Kapabilitas Bisnis**, bukan kata benda database. |
||||
|
|
||||
|
| ✅ Benar | ❌ Salah | Alasan | |
||||
|
|---|---|---| |
||||
|
| `Onboarding/` | `Users/` | Mendeskripsikan tahap siklus hidup, bukan tabel DB. | |
||||
|
| `AccessControl/` | `Roles/` | Mendeskripsikan kapabilitas, bukan nama resource. | |
||||
|
| `Governance/` | `Admin/` | Mendeskripsikan niat kepatuhan/pengawasan. | |
||||
|
| `Passwords/` | `Auth/` | Ruang lingkup yang sempit dan presisi. | |
||||
|
| `Registration/` | `Signup/` | Menggunakan bahasa formal sistem. | |
||||
|
|
||||
|
**Aturan:** Jika nama folder juga merupakan nama Model Eloquent yang valid, maka penamaan tersebut salah. |
||||
|
|
||||
|
### 10.3 Nama Kelas Action |
||||
|
|
||||
|
Actions harus dinamai berdasarkan **Niat Bisnis yang spesifik** yang mereka penuhi. Gunakan pola kata kerja aktif + kata benda bisnis. |
||||
|
|
||||
|
| ✅ Benar | ❌ Salah | Alasan | |
||||
|
|---|---|---| |
||||
|
| `ProvisionNewUser` | `CreateUser` | Mendeskripsikan *siapa* yang memicu dan *mengapa*. | |
||||
|
| `SuspendUser` | `DeleteUser` | Mengungkapkan konsekuensi bisnis (pencabutan lunak, bukan penghancuran). | |
||||
|
| `UpdateUserRole` | `SaveRole` | Eksplisit mengenai subjek dan properti yang diubah. | |
||||
|
| `RegisterUser` | `StoreUser` | Bahasa domain, bukan bahasa HTTP verb. | |
||||
|
| `SendPasswordResetLink` | `ResetPassword` | Mencerminkan efek samping sebenarnya yang dipicu. | |
||||
|
|
||||
|
Nama-nama CRUD (`CreateCategory`, `UpdateSetting`) hanya dapat diterima untuk tabel pencarian sepele (lookup tables) yang **tidak memiliki efek samping**. |
||||
|
|
||||
|
### 10.4 Nama Kelas DTO |
||||
|
|
||||
|
DTO dinamai berdasarkan Action yang mereka layani, dengan akhiran `DTO`. |
||||
|
|
||||
|
| Action | DTO | |
||||
|
|---|---| |
||||
|
| `ProvisionNewUser` | `ProvisionUserDTO` | |
||||
|
| `UpdateUser` | `UpdateUserDTO` | |
||||
|
| `CreateSystemRole` | `CreateRoleDTO` | |
||||
|
|
||||
|
### 10.5 Nama Kelas Event |
||||
|
|
||||
|
Events adalah **fakta masa lalu (past-tense)** mengenai sesuatu yang telah terjadi dalam domain. Nama kelas harus secara gramatikal menyatakan kebenaran yang sudah selesai. |
||||
|
|
||||
|
| ✅ Benar | ❌ Salah | Alasan | |
||||
|
|---|---|---| |
||||
|
| `UserWasProvisioned` | `UserProvisioned` | Bentuk *past-tense* eksplisit menghilangkan ambiguitas. | |
||||
|
| `UserWasSuspended` | `UserSuspended` | Membaca sebagai state, bukan sebagai fakta yang sudah selesai. | |
||||
|
| `UserLoggedIn` | `LoginEvent` | Pola kata benda + kata kerja; menghindari akhiran `Event`. | |
||||
|
| `UserEmailVerified` | `EmailVerification` | Mendeskripsikan tindakan yang telah selesai. | |
||||
|
|
||||
|
**Aturan:** Jangan pernah mengakhiri Events dengan `Event` (misal, `UserRegisteredEvent` itu salah). Namespace `Events\` sudah mengomunikasikan jenisnya. |
||||
|
|
||||
|
### 10.6 Nama Kelas Listener |
||||
|
|
||||
|
Listeners mendeskripsikan **reaksi aktif** terhadap sebuah event menggunakan frasa kata kerja imperatif. |
||||
|
|
||||
|
| ✅ Benar | ❌ Salah | Alasan | |
||||
|
|---|---|---| |
||||
|
| `SendSignInActivityNotification` | `UserLoggedInListener` | Mendeskripsikan apa yang *dilakukan* listener, bukan apa yang diresponsnya. | |
||||
|
| `DispatchWelcomeNotification` | `WelcomeListener` | Kata kerja imperatif membuat niat menjadi sangat jelas. | |
||||
|
|
||||
|
**Aturan:** Jangan pernah mengakhiri Listeners dengan `Listener` di nama kelasnya. Namespace `Listeners\` sudah mengomunikasikan jenisnya. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 11. Prompt Agen AI (Instruksi Sistem) |
||||
|
|
||||
|
**Untuk Pengembang:** Salin dan tempel blok di bawah ini ke obrolan agen AI Anda atau instruksi sistem sebelum memintanya untuk menulis atau merefaktor kode di repositori ini. |
||||
|
|
||||
|
```text |
||||
|
You are an autonomous Senior Laravel Architect specializing in Pragmatic Domain-Driven Design (DDD) and Event-Driven Architecture. You must strictly obey the "Antigravity" rules of this repository. |
||||
|
|
||||
|
### 1. The HTTP Gateway (Delivery Layer) |
||||
|
- Lives in `app/Http/Controllers/`, `app/Http/DataTables/`, or `app/Livewire/`. |
||||
|
- Responsibilities: HTTP validation, rate limiting, session management (`Auth::login`, `session()->regenerate()`), and redirects. |
||||
|
- HARD RESTRICTION: The Gateway MUST NEVER call `Model::create()`, `Model::update()`, or `Hash::make()`. It must map validated data into a DTO and pass it to a Domain Action. |
||||
|
|
||||
|
### 2. The Domain (Business Logic) |
||||
|
- Lives inside `app/Domains/{Concept}/`. |
||||
|
- Responsibilities: DTO definitions, Actions (database writes), Models, and Events. |
||||
|
- HARD RESTRICTION: The Domain MUST NEVER read from the `request()` helper, manipulate cookies/sessions, or throw HTTP-specific exceptions (like `ValidationException`). |
||||
|
|
||||
|
### 3. Execution & Workflow |
||||
|
- DTOs: Must be strictly typed readonly classes. |
||||
|
- Actions: Must represent a specific Business Intent (e.g., `ProvisionNewUser` not `SaveUser`). Actions that perform multiple database writes must wrap them in a `DB::transaction()`. |
||||
|
- Action Composition: Inject Actions into other Actions via the constructor to reuse logic (e.g., injecting `AccessControl\UpdateUserRole` into `Onboarding\ProvisionNewUser`). |
||||
|
- Events: Use Events to decouple side effects (Notifications, Activity Logs). Events must be **past-tense** (e.g., `UserWasProvisioned` not `UserProvisioned`). |
||||
|
|
||||
|
### 4. Naming Rules |
||||
|
- **Domains**: Singular Business Concepts, never database nouns (✅ `Identity` ❌ `Users`). |
||||
|
- **Capability Folders**: Name subdirectories after business capabilities, never after models (✅ `Onboarding/`, `AccessControl/`, `Governance/` ❌ `Users/`, `Roles/`). |
||||
|
- **Actions**: Active verb + business noun (✅ `ProvisionNewUser` ❌ `CreateUser`). |
||||
|
- **Events**: Past-tense completed facts, no `Event` suffix (✅ `UserWasProvisioned` ❌ `UserProvisionedEvent`). |
||||
|
- **Listeners**: Imperative active-verb phrases, no `Listener` suffix (✅ `SendSignInActivityNotification` ❌ `UserLoggedInListener`). |
||||
|
|
||||
|
### 5. File Generation Rules |
||||
|
* NEVER use standard Laravel generators (e.g., `php artisan make:model`) for Domain classes. |
||||
|
* ALWAYS use the custom `domain:make` command to create Domain files. |
||||
|
* Example: `php artisan domain:make action Identity Onboarding/ProvisionNewUser` |
||||
|
* Supported types: `model`, `action`, `dto`, `enum`, `event`, `listener`, `notification`, `policy`, `query`, `provider`, `export`, `mapper`, `scope`, `trait`, `mailable`. |
||||
|
* Examples for the Integration layer: |
||||
|
* `php artisan domain:make export Identity UserExport --model=User` |
||||
|
* `php artisan domain:make mapper Identity User` → generates `Integration/Mappers/UserDataMapper.php` |
||||
|
* Excel ingestion (Import) classes live in the **Gateway layer** at `app/Http/Ingestion/Excel/` — do NOT generate them with `domain:make`. |
||||
|
* Queries: For complex database reads (e.g., massive filtering or reporting), create a Query class in app/Domains/{Concept}/Queries/. Queries are read-only, do not use transactions, do not mutate state, and do not dispatch events. |
||||
|
|
||||
|
### 6. Testing Strategy |
||||
|
* Always write tests using Pest PHP. |
||||
|
* When generating new features (Actions/DTOs/Models), create corresponding tests in `tests/Feature/` or `tests/Unit/`. |
||||
|
* Ensure 100% adherence to Architectural tests (check `tests/Architecture/` for existing rules). |
||||
|
* When writing tests, use `Event::fake()`, `Queue::fake()`, `Notification::fake()` to isolate side effects. |
||||
|
* For dependency-injected services, use `$this->mock()` to define expected behaviors. |
||||
|
|
||||
|
Write modern PHP 8.4+ code with strict typing. Ensure all PSR-4 namespaces perfectly match the directory structure. |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 12. Alat Pengembangan & Generator |
||||
|
|
||||
|
Untuk mempertahankan struktur folder yang ketat dari arsitektur Antigravity, **jangan gunakan perintah `make:` standar (seperti `make:model`) untuk file Domain.** Gunakan perintah kustom `domain:make` untuk menghasilkan kelas di namespace `app/Domains/` yang benar. |
||||
|
|
||||
|
### Perintah `domain:make` |
||||
|
|
||||
|
**Signature:** |
||||
|
|
||||
|
```bash |
||||
|
php artisan domain:make {type} {domain} {name} [options] |
||||
|
|
||||
|
``` |
||||
|
|
||||
|
**Argumen:** |
||||
|
|
||||
|
* `type`: Jenis file yang akan dibuat. Didukung: `model`, `action`, `dto`, `enum`, `event`, `listener`, `notification`, `policy`, `scope`, `trait`, `query`, `provider`, `export`, `mapper`, `mailable`. |
||||
|
* `domain`: Folder Domain target (misal, `Identity`, `Account`, `System`). |
||||
|
* `name`: Nama kelas. Mendukung pengelompokan sub-direktori (misal, `Management/ProvisionNewUser`). |
||||
|
|
||||
|
**Opsi:** |
||||
|
|
||||
|
* `--factory`: Menghasilkan factory database terkait (Hanya Models). |
||||
|
* `--migration`: Menghasilkan file migrasi database (Hanya Models). |
||||
|
* `--model=`: Mengaitkan kelas ekspor dengan model Eloquent (Hanya Exports). |
||||
|
|
||||
|
### Menyesuaikan Generator (Stubs) |
||||
|
|
||||
|
Semua template `domain:make` disimpan sebagai file `.stub` di `app/Console/stubs/domain-make/`. Anda dapat dengan bebas mengedit stub ini untuk menyesuaikan *boilerplate* default dengan kebutuhan spesifik proyek Anda (misalnya, mengubah metode default di DTO). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 13. Manajemen File Universal (Domain System) |
||||
|
|
||||
|
Penanganan file (unggahan, lampiran, pemangkasan gambar, dan penghapusan) adalah kemampuan yang dibagikan secara universal. Untuk mencegah setiap domain menulis logika penyimpanan filenya sendiri, semua file fisik dikelola oleh mesin terpusat di dalam domain **`System`**. |
||||
|
|
||||
|
### Mesin Polimorfik |
||||
|
|
||||
|
Kami tidak menambahkan path file secara langsung ke tabel bisnis (misalnya, tidak ada kolom `avatar_path` di tabel `users`). Alih-alih, kami menggunakan tabel `files` terpusat dan model `System\Models\File`. |
||||
|
|
||||
|
* File dilampirkan secara **polimorfik** ke entitas apa pun di dalam aplikasi. |
||||
|
* Tabel `files` menyertakan kolom string `relation_name` bertipe ketat (misalnya, `'avatar'`) untuk mencegah terjadinya tabrakan antar jenis file yang dilampirkan ke model yang sama. |
||||
|
|
||||
|
### Trait Konsumen |
||||
|
|
||||
|
Ketika Domain Model (seperti `User` atau `Invoice`) perlu menerima lampiran file, model tersebut akan menarik trait `HasFile`. Trait ini menyediakan pembuat relasi terisolasi. |
||||
|
|
||||
|
```php |
||||
|
namespace App\Domains\Identity\Models; |
||||
|
|
||||
|
use Illuminate\Database\Eloquent\Model; |
||||
|
use App\Domains\System\Traits\HasFile; |
||||
|
use Illuminate\Database\Eloquent\Relations\MorphOne; |
||||
|
|
||||
|
class User extends Model |
||||
|
{ |
||||
|
use HasFile; |
||||
|
|
||||
|
// String 'avatar' secara sempurna mengisolasi file ini di dalam database |
||||
|
public function avatar(): MorphOne |
||||
|
{ |
||||
|
return $this->singleFile('avatar'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
``` |
||||
|
|
||||
|
### File Action (Metadata via DTO) |
||||
|
|
||||
|
Karena objek `Illuminate\Http\UploadedFile` milik Laravel merupakan objek yang kompleks, kami melewatkannya bersama dengan `FileDTO` yang diketik secara ketat (strictly-typed) yang berisi metadata (model target, disk, dan nama relasi). Ini memastikan Gateway tetap bersih sementara Domain menerima semua konteks yang diperlukan. |
||||
|
|
||||
|
* **`UploadAndAttachFile`**: Action dasar. Ini menyimpan file fisik ke disk dan membuat rekaman database polimorfik. |
||||
|
* **`ReplaceSingleFile`**: Digunakan untuk penggantian 1-ke-1 (seperti mengganti avatar). Ia menghapus file lama secara aman sebelum mendelegasikan unggahan baru kembali ke Action dasar. |
||||
|
|
||||
|
**Contoh Gateway:** |
||||
|
|
||||
|
```php |
||||
|
$action->execute( |
||||
|
newFile: $request->file('photo'), |
||||
|
dto: new FileDTO( |
||||
|
modelType: $user->getMorphClass(), |
||||
|
modelId: $user->id, |
||||
|
relationName: 'avatar', |
||||
|
disk: 'local', |
||||
|
directory: 'avatars', |
||||
|
options: [], |
||||
|
uploaderId: auth()->id(), |
||||
|
) |
||||
|
); |
||||
|
|
||||
|
``` |
||||
|
|
||||
|
### Helper Asset Sistem |
||||
|
|
||||
|
Untuk menghindari polusi namespace global Laravel dengan file `app/helpers.php` sampah, kami mengelola file helper yang terikat ketat pada domain di `app/Domains/System/Helpers/asset.php`. File ini di-autoload via `composer.json` dan menyediakan fungsi `asset_static()` untuk menyelesaikan (resolve) file publik dan privat di view Blade kita secara elegan. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 14. Pengaturan Global & State Aplikasi |
||||
|
|
||||
|
Pengaturan yang mendikte *state* (status) *runtime* aplikasi (Zona Waktu, Lokalisasi, Tag SEO) dikelola oleh domain `System` untuk memastikan performa tinggi dan kesadaran konteks. |
||||
|
|
||||
|
* **Memoization & Singletons:** Query `GetSystemSettings` didaftarkan sebagai Singleton di `SystemServiceProvider`. Ia mengambil data dari database/cache *sekali* saja dan menyimpannya di memori lokal PHP selama durasi request berjalan. |
||||
|
* **Contextual Middlewares:** Kami menggunakan *middleware* terdedikasi (`HandlePreferredLanguage`, `HandlePreferredTimezone`) untuk memeriksa preferensi pengguna yang terautentikasi secara dinamis, lalu menggunakan (fallback ke) pengaturan global jika tidak ada preferensi yang ditemukan. |
||||
|
* **View Composers:** Variabel *layout* global (seperti Logo dan Nama Web) disuntikkan ke global via View Composers di dalam Service Provider, untuk mencegah penggunaan direktif `@inject` yang berulang. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 15. Pencatatan Audit (Audit Logging) & Pelacakan |
||||
|
|
||||
|
Semua mutasi database yang penting dilacak untuk mempertahankan buku besar (ledger) historis yang patuh (compliant). |
||||
|
|
||||
|
* **Model Auditing:** Kami memanfaatkan Eloquent event untuk secara otomatis mencatat perubahan baris. |
||||
|
* **Complex Relation Auditing:** Melacak perubahan hubungan many-to-many (seperti `syncPermissions` milik Spatie) akan melewati (bypass) standar Eloquent event. Oleh karena itu, kami secara eksplisit mendispatch Custom Audit Event langsung di dalam Domain Action terkait (misalnya, `UpdateRolePermissions`). Ini menjamin state "Sebelum" dan "Sesudah" terekam dengan rapi di dalam satu baris transaksional tunggal. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 16. UI Dinamis & Interoperabilitas Livewire |
||||
|
|
||||
|
Saat membangun antarmuka berbasis data (seperti formulir pengaturan dinamis), kami memanfaatkan pola **Renderable Enum** dikombinasikan dengan komponen dinamis bawaan Laravel. |
||||
|
|
||||
|
* Enum bertindak sebagai **Penyedia Metadata** (mengembalikan nama string dari komponen Blade target). |
||||
|
* Kami menggunakan `<x-dynamic-component>` di dalam file Blade untuk menukar elemen UI secara dinamis. |
||||
|
* **KRITIS:** Enum tidak boleh sama sekali mengembalikan string HTML mentah yang dikompilasi melalui *facade* `Blade`. Melakukan hal tersebut akan merusak mesin DOM-diffing milik Livewire dan memutuskan binding `wire:model`. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 17. Ekspor & Impor Excel |
||||
|
|
||||
|
Komponen Laravel `resources/views/components/datatables/⚡excel-manager.blade.php` (didaftarkan sebagai `<livewire:datatables.excel-manager>`) menyediakan mekanisme berbasis *queue* (antrean) yang dapat digunakan kembali untuk mengimpor dan mengekspor file Excel pada halaman DataTable apa pun. Ini adalah **komponen Laravel file tunggal terpadu** — logika kelas PHP dan templat Blade berdampingan pada file yang sama, mengikuti konvensi penamaan `⚡` yang digunakan di seluruh komponen Laravel dalam proyek ini. |
||||
|
|
||||
|
### Gambaran Arsitektur |
||||
|
|
||||
|
Komponen ini bergantung pada tiga lapisan yang berkolaborasi: |
||||
|
|
||||
|
1. **Komponen Laravel** (`⚡excel-manager.blade.php`) — Menangani *state* UI, unggahan file melalui `WithFilePond`, validasi, dan Livewire *event listeners*. Semua *props* diamankan menggunakan `#[Locked]` untuk mencegah perusakan (tampering) di sisi klien. |
||||
|
2. **Dekorator `StyledExport`** (`App\UI\Support\Excel\StyledExport`) — *Wrapper* lapisan UI yang memperkaya *Export* domain apa pun dengan penataan gaya (styling) visual yang terstandar (baris *header* yang dibekukan, orientasi *landscape*, tepi yang tipis, penyejajaran tengah, dan kolom *auto-sized*) tanpa mencemari kelas *Export* domain dengan logika presentasi. |
||||
|
3. **Pipeline Pemberitahuan Event-Driven** — Setelah file ekspor ditulis ke *disk*, tugas antrean `NotifyExportReady` mendispatch event `ExportCompleted`, yang ditangani oleh *listener* `SendExportReportEmail` untuk mengirimkan file melalui email. |
||||
|
|
||||
|
### Cara Kerjanya |
||||
|
|
||||
|
* **Impor:** Pengguna mengunggah file `.xlsx` melalui modal FilePond. File disimpan ke `local/excel/import/{resourceName}` dan sebuah instansiasi impor baru dikonstruksi menggunakan UUID (`$importId`) dan ID pengguna yang terautentikasi (`$initiatorId`) sebelum didispatch ke antrean menggunakan `Excel::queueImport()`. Kelas Ingesti (Ingestion) berada di `app/Http/Ingestion/` (Lapisan Gateway) dan mengimplementasikan `WithChunkReading` (ukuran *chunk*: 200 baris) agar tetap berada di dalam batas memori *shared-hosting*. Sebuah toast peringatan (`ui.excel.import.success`) dimunculkan seketika setelah masuk antrean. |
||||
|
|
||||
|
* **Ekspor:** Sebuah Livewire browser event (`export-excel`) — didispatch oleh tombol Export DataTable — memicu metode `export()` melalui atribut `#[On('export-excel')]`. Kelas Export domain dibungkus (wrapped) di dalam `StyledExport` lalu didispatch menggunakan `Excel::queue()`. Job chain menambahkan `NotifyExportReady`, yang kemudian mendispatch event `ExportCompleted`, yang pada akhirnya ditangani oleh listener `SendExportReportEmail` untuk mengirim file tersebut sebagai lampiran email ke pengguna. Sebuah toast sukses (`ui.excel.export.success`) dimunculkan dengan segera setelah masuk antrean. |
||||
|
|
||||
|
### Properti Komponen (Props) |
||||
|
|
||||
|
| Prop | Tipe | Deskripsi | |
||||
|
| --- | --- | --- | |
||||
|
| `importClass` | `string` | Nama kelas kualifikasi penuh (fully-qualified) dari Gateway Ingestion class (contoh, `App\Http\Ingestion\Excel\Identity\UserImport`). | |
||||
|
| `exportClass` | `string` | Nama kelas kualifikasi penuh dari domain Export (contoh, `App\Domains\Identity\Exports\UserExport`). | |
||||
|
| `resourceName` | `string` | Sebuah kata (slug) yang digunakan untuk menamai file impor yang disimpan serta nama file ekspor yang bertanda waktu (contoh, `user`). | |
||||
|
|
||||
|
### Penggunaan |
||||
|
|
||||
|
Sematkan komponen di dalam halaman DataTable (Blade view) mana pun. Semua props harus disediakan berupa string nama kelas PHP yang terkualifikasi secara penuh: |
||||
|
|
||||
|
```blade |
||||
|
<livewire:datatables.excel-manager |
||||
|
:export-class="\App\Domains\Identity\Exports\UserExport::class" |
||||
|
:import-class="\App\Http\Ingestion\Excel\Identity\UserImport::class" |
||||
|
resource-name="user" |
||||
|
/> |
||||
|
``` |
||||
|
|
||||
|
Tombol Export milik DataTable harus mendispatch event Livewire `export-excel`, sedangkan tombol Import harus membuka Bootstrap modal `#excel-import-modal`: |
||||
|
|
||||
|
```php |
||||
|
// Di dalam file builder html() DataTable Anda: |
||||
|
Button::make('excel') |
||||
|
->action("Livewire.dispatch('export-excel')"), |
||||
|
|
||||
|
Button::make('excel') |
||||
|
->action("$('#excel-import-modal').modal('show')"), |
||||
|
``` |
||||
|
|
||||
|
### Menghasilkan Kelas Export & Mapper |
||||
|
|
||||
|
Gunakan perintah `domain:make` untuk membuat kelas lapisan Export dan Integration: |
||||
|
|
||||
|
```bash |
||||
|
# Menghasilkan kelas Export domain |
||||
|
php artisan domain:make export Identity UserExport --model=User |
||||
|
|
||||
|
# Menghasilkan Integration Mapper (akan meng-append akhiran DataMapper secara otomatis) |
||||
|
php artisan domain:make mapper Identity User |
||||
|
# → app/Domains/Identity/Integration/Mappers/UserDataMapper.php |
||||
|
``` |
||||
|
|
||||
|
Kelas Export Domain wajib mengimplementasikan `FromQuery & WithHeadings & WithMapping & WithColumnFormatting`. Dekorator `StyledExport` akan menerapkan semua format visual secara otomatis saat diproses antrean — **jangan** mengimplementasikan `WithStyles` secara langsung di kelas Export domain. |
||||
|
|
||||
|
> **Lapisan Gateway:** Kelas Excel Ingestion (Import) berada di dalam `app/Http/Ingestion/Excel/` dan **tidak** dihasilkan oleh generator `domain:make`. Buatlah secara manual atau gunakan `make:class` sebagai kelas PHP standar yang mengimplementasikan `ToCollection`, `WithHeadingRow`, dan `WithChunkReading`. |
||||
|
|
||||
|
### Pipeline Pemberitahuan |
||||
|
|
||||
|
Alur pemberitahuan ekspor mengikuti rantai *event-driven* berbasis antrean secara ketat: |
||||
|
|
||||
|
``` |
||||
|
Excel::queue(StyledExport, $path) |
||||
|
└─> NotifyExportReady (Job) [app/Domains/System/Jobs/] |
||||
|
└─> ExportCompleted::dispatch (Event) [app/Domains/System/Events/] |
||||
|
└─> SendExportReportEmail (Listener) [app/Domains/System/Listeners/] |
||||
|
└─> ExcelExportEmail (Mailable) [app/Domains/System/Mail/] |
||||
|
``` |
||||
|
|
||||
|
Pemberitahuan impor akan dikirim langsung oleh kelas Import domain itu sendiri sesaat setelah penyelesaian, dengan menggunakan `ExcelImportEmail` dari namespace `App\Domains\System\Mail\` yang sama. |
||||
|
|
||||
|
### Mailables |
||||
|
|
||||
|
Kedua kelas Mailable berada pada **domain System**, bukan di namespace utama `App\Mail\`: |
||||
|
|
||||
|
* **`App\Domains\System\Mail\ExcelImportEmail`** — Dikirim saat unggahan *queued import* telah selesai. Menggunakan terjemahan dari `domains/system.notifications.excel.import_email.*`. |
||||
|
* **`App\Domains\Identity\Mail\Registration\WelcomeEmail`** — Contoh mailable spesifik domain. |
||||
|
* **`App\Domains\System\Mail\ExcelExportEmail`** — Dikirim saat *queued export* siap, dilampirkan menggunakan file dari disk `local`. Menggunakan terjemahan dari `domains/system.notifications.excel.export_email.*`. |
||||
|
|
||||
|
### Kunci Terjemahan (Translation Keys) |
||||
|
|
||||
|
| File | Kunci (Key) | Tujuan | |
||||
|
| --- | --- | --- | |
||||
|
| `lang/{locale}/ui.php` | `ui.excel.import.file_label` | Label unggahan FilePond di dalam modal impor. | |
||||
|
| `lang/{locale}/ui.php` | `ui.excel.import.success` | Pesan Toast muncul setelah impor masuk antrean. | |
||||
|
| `lang/{locale}/ui.php` | `ui.excel.export.success` | Pesan Toast muncul setelah ekspor masuk antrean. | |
||||
|
| `lang/{locale}/domains/system.php` | `notifications.excel.import_email.*` | Isi (body) email untuk pemberitahuan selesai impor. | |
||||
|
| `lang/{locale}/domains/system.php` | `notifications.excel.export_email.*` | Isi (body) email untuk pemberitahuan ekspor sudah siap. | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 18. Testing |
||||
|
|
||||
|
Proyek ini menggunakan **Pest PHP** untuk pengujian. |
||||
|
|
||||
|
```bash |
||||
|
composer test |
||||
|
``` |
||||
|
|
||||
|
Tes terletak di direktori `tests/` dan mengikuti konvensi standar Laravel (Feature dan Unit). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 19. Variabel Lingkungan (Environment Variables) |
||||
|
|
||||
|
Variabel utama yang digunakan di `.env`: |
||||
|
|
||||
|
- `APP_NAME`: Nama aplikasi. |
||||
|
- `APP_ENV`: Lingkungan aplikasi (`local`, `production`, dll.). |
||||
|
- `APP_KEY`: Kunci enkripsi aplikasi. |
||||
|
- `DB_CONNECTION`: Driver database (`sqlite`, `mysql`, `pgsql`). |
||||
|
- `QUEUE_CONNECTION`: Driver antrean (default: `database`). |
||||
|
- `MAIL_MAILER`: Driver email (default: `log`). |
||||
|
|
||||
|
Lihat `.env.example` untuk daftar lengkap opsi yang tersedia. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 20. Lisensi |
||||
|
|
||||
|
Proyek ini dilisensikan di bawah **Lisensi MIT**. |
||||
@ -0,0 +1,599 @@ |
|||||
|
# Antigravity Architecture: Laravel Modular Monolith |
||||
|
|
||||
|
[](https://laravel.com) |
||||
|
[](https://www.php.net) |
||||
|
[](https://vitejs.dev) |
||||
|
[](https://pestphp.com) |
||||
|
|
||||
|
Welcome to the repository. This application is built using a strict **Pragmatic Domain-Driven Design (DDD)** architecture. We refer to this as the "Antigravity" architecture because it prevents the codebase from collapsing under its own weight as it scales. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. Technical Stack |
||||
|
|
||||
|
- **Backend:** PHP 8.4+ & Laravel 13.0 |
||||
|
- **Frontend:** Vite, AlpineJS, Livewire, Tailwind CSS |
||||
|
- **Database:** SQLite (default), MySQL, or PostgreSQL |
||||
|
- **Testing:** Pest PHP |
||||
|
- **Package Managers:** Composer (PHP), NPM (JS) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. Requirements |
||||
|
|
||||
|
- **PHP:** ^8.4 |
||||
|
- **Node.js:** Latest LTS recommended |
||||
|
- **Composer:** ^2.0 |
||||
|
- **Extensions:** `ext-zip`, `ext-pdo_sqlite` (if using SQLite) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. Setup & Installation |
||||
|
|
||||
|
The project includes a unified setup script in `composer.json`. |
||||
|
|
||||
|
```bash |
||||
|
# 1. Clone the repository |
||||
|
git clone <repo-url> |
||||
|
cd laravel-base |
||||
|
|
||||
|
# 2. Run the automated setup |
||||
|
# This installs PHP/JS deps, creates .env, generates key, and runs migrations |
||||
|
composer setup |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. Running the Application |
||||
|
|
||||
|
Use the pre-configured development command which runs the server, queue listener, logs, and Vite concurrently: |
||||
|
|
||||
|
```bash |
||||
|
composer dev |
||||
|
``` |
||||
|
|
||||
|
The application will be available at `http://localhost:8000`. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. Scripts & Commands |
||||
|
|
||||
|
Available via Composer: |
||||
|
- `composer setup`: Initial project bootstrap. |
||||
|
- `composer dev`: Start development environment (Server + Queue + Logs + Vite). |
||||
|
- `composer test`: Run the test suite. |
||||
|
- `php artisan domain:make`: Custom generator for the DDD architecture (see Section 12). |
||||
|
|
||||
|
Available via NPM: |
||||
|
- `npm run dev`: Start Vite dev server. |
||||
|
- `npm run build`: Build assets for production. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6. Project Structure |
||||
|
|
||||
|
```text |
||||
|
. |
||||
|
├── app/ |
||||
|
│ ├── Attributes/ <-- PHP 8 Attributes (SEO, Layout) |
||||
|
│ ├── Domains/ <-- Core Business Logic (The Vault) |
||||
|
│ ├── Http/ <-- Web/API Gateway (Controllers, Requests, DataTables) |
||||
|
│ ├── Livewire/ <-- Livewire Components & Forms |
||||
|
│ ├── Providers/ <-- Service Providers |
||||
|
│ └── UI/ <-- UI-specific logic (Actions, Enums) |
||||
|
├── bootstrap/ <-- App bootstrap logic |
||||
|
├── config/ <-- Laravel configuration files |
||||
|
├── database/ <-- Migrations, factories, and seeders |
||||
|
├── public/ <-- Web server entry point (index.php) and static assets |
||||
|
├── resources/ |
||||
|
│ ├── lang/ <-- Localization files |
||||
|
│ ├── views/ <-- Blade templates & Livewire components |
||||
|
│ └── css/js/ <-- Frontend source assets |
||||
|
├── routes/ <-- Web, API, and Console routes |
||||
|
├── tests/ <-- Pest test suite |
||||
|
├── storage/ <-- Logs, file uploads, and cache |
||||
|
└── vite.config.js <-- Vite configuration |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7. The Core Philosophy |
||||
|
|
||||
|
This architecture enforces a hard, physical boundary between **Delivery** (how the user interacts with the app) and **Business Logic** (what the app actually does). |
||||
|
|
||||
|
* **The Gateway (HTTP Layer):** Controllers, Livewire, and Volt components are purely traffic cops. They handle web sessions, cookies, redirects, and form validation. |
||||
|
* **The Domain (The Vault):** Actions, DTOs, and Models inside `app/Domains/` handle the actual business rules, database mutations, and external API calls. |
||||
|
|
||||
|
> **The Golden Rule:** The Domain must remain completely ignorant of the web. You must never use `request()`, `session()`, or throw HTTP exceptions inside the `app/Domains/` directory. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 8. Directory Structure |
||||
|
|
||||
|
The application is divided by **Business Concepts**, not technical features. |
||||
|
|
||||
|
```text |
||||
|
app/ |
||||
|
├── Attributes/ <-- PHP 8 Attributes (e.g., #[Seo], #[LayoutData]) |
||||
|
├── Console/ |
||||
|
│ ├── Commands/ <-- Custom Artisan commands (DomainMakeCommand, CleanOrphanedFiles) |
||||
|
│ └── stubs/ <-- Custom code generation stubs |
||||
|
├── Http/ <-- The Gateway (HTTP Layer) |
||||
|
│ ├── Controllers/ |
||||
|
│ │ ├── Api/ |
||||
|
│ │ │ └── V1/ <-- Versioned API controllers |
||||
|
│ │ └── Web/ |
||||
|
│ │ ├── Auth/ <-- Authentication controllers |
||||
|
│ │ ├── Identity/ <-- User & role management controllers |
||||
|
│ │ └── Account/ <-- Profile management controllers |
||||
|
│ ├── DataTables/ <-- Livewire DataTable configurations |
||||
|
│ ├── Ingestion/ <-- Excel Import/Ingestion classes |
||||
|
│ ├── Middleware/ <-- HandlePreferredLanguage, HandleSeoSetting, etc. |
||||
|
│ ├── Requests/ |
||||
|
│ │ ├── Api/ <-- API form requests |
||||
|
│ │ └── Web/ <-- Web form requests |
||||
|
│ └── Resources/ <-- API resources (LookupResource, SuccessResource, etc.) |
||||
|
├── Livewire/ |
||||
|
│ ├── Concerns/ <-- Shared Livewire traits (WithModal, WithToast) |
||||
|
│ └── Forms/ <-- Livewire Form Objects |
||||
|
├── Providers/ <-- AppServiceProvider, EventServiceProvider, UiServiceProvider |
||||
|
├── UI/ |
||||
|
│ ├── Actions/ <-- UI-layer actions (SetSeoMetadata, ApplyLayoutMetadata) |
||||
|
│ ├── Enums/ <-- UI-specific enums (FileType, InputType) |
||||
|
│ └── Support/ <-- UI helper classes (LayoutState, StyledExport) |
||||
|
└── Domains/ |
||||
|
├── Identity/ <-- Business Concept: Authentication & Users |
||||
|
│ ├── Actions/ <-- Capability-grouped mutations |
||||
|
│ ├── DTOs/ <-- Capability-grouped Data Transfer Objects |
||||
|
│ ├── Enums/ |
||||
|
│ ├── Events/ <-- Past-tense truths |
||||
|
│ ├── Exports/ |
||||
|
│ ├── Integration/ <-- External system mappers |
||||
|
│ ├── Listeners/ <-- Active-verb handlers |
||||
|
│ ├── Models/ <-- User, Role, Permission |
||||
|
│ ├── Notifications/ |
||||
|
│ ├── Policies/ |
||||
|
│ ├── Providers/ |
||||
|
│ ├── Queries/ <-- Complex Reads |
||||
|
│ └── Scopes/ <-- Eloquent Global Scopes |
||||
|
├── Account/ <-- Business Concept: Profiles & Billing |
||||
|
│ ├── Actions/ |
||||
|
│ ├── DTOs/ |
||||
|
│ ├── Enums/ |
||||
|
│ ├── Listeners/ |
||||
|
│ ├── Models/ |
||||
|
│ └── Providers/ |
||||
|
└── System/ <-- Business Concept: Cross-cutting Infrastructure |
||||
|
├── Actions/ |
||||
|
├── Casts/ <-- Custom Eloquent casts |
||||
|
├── DTOs/ |
||||
|
├── Enums/ |
||||
|
├── Events/ |
||||
|
├── Helpers/ <-- Domain-specific helpers (asset.php) |
||||
|
├── Jobs/ <-- Domain-specific background jobs |
||||
|
├── Listeners/ |
||||
|
├── Mail/ <-- Domain-specific mailables |
||||
|
├── Models/ <-- File, SystemSettings, Backup |
||||
|
├── Policies/ |
||||
|
├── Providers/ <-- SystemServiceProvider |
||||
|
├── Queries/ <-- GetSystemSettings, GetModelAuditLog |
||||
|
├── Support/ |
||||
|
├── Traits/ <-- Domain-specific traits (HasFile) |
||||
|
└── ... |
||||
|
|
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 9. The Rules of Engagement |
||||
|
|
||||
|
### DTOs (Data Transfer Objects) |
||||
|
|
||||
|
All untrusted data from the Gateway must be packed into a strictly typed, readonly DTO before entering the Domain. |
||||
|
|
||||
|
* DTOs only contain data meant to change state. |
||||
|
* Do not put Eloquent models inside DTOs. Pass the Model as a separate parameter to the Action. |
||||
|
|
||||
|
### Actions (The Executors) |
||||
|
|
||||
|
Actions are the only place database mutations (`create`, `update`, `delete`, `syncRoles`) are allowed. |
||||
|
|
||||
|
* Actions must have a single responsibility. |
||||
|
* Use `DB::transaction()` inside Actions when multiple database writes (e.g., creating a user and assigning a Spatie role) must succeed or fail together. |
||||
|
* Use **Action Composition** (injecting one Action into another via the constructor) to reuse logic without duplicating code. |
||||
|
|
||||
|
### Events & Listeners |
||||
|
|
||||
|
Use Event-Driven Architecture for all side effects (emails, logging, background processing). |
||||
|
|
||||
|
* The Gateway dispatches Events for non-mutating session facts (e.g., `UserLoggedIn`). |
||||
|
* Actions dispatch Events immediately after a successful state mutation (e.g., `UserWasProvisioned`). |
||||
|
* Listeners handle the reaction outside the main HTTP lifecycle and are the **only** place `Notification::send()`, `Mail::send()`, or logging calls are made in response to a Domain Event. |
||||
|
* Events and Listeners must be **grouped under the same capability folder** as the Action that dispatches them (e.g., `Events/Onboarding/`, `Listeners/Onboarding/`). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 10. Naming Conventions |
||||
|
|
||||
|
This architecture uses a strict, intentional naming language. Every name must communicate **Business Intent**, not database operations. |
||||
|
|
||||
|
### 10.1 Domain Folders (`app/Domains/{Name}/`) |
||||
|
|
||||
|
Domain names are **Business Concepts**, not technical layers. They must be a singular noun that describes a bounded context. |
||||
|
|
||||
|
| ✅ Correct | ❌ Wrong | Why | |
||||
|
|---|---|---| |
||||
|
| `Identity` | `Users` | Identity covers auth, roles, and user lifecycle — not just a table | |
||||
|
| `Account` | `Profile` | Account owns the full user account surface, not one model | |
||||
|
| `System` | `Utils` / `Helpers` | System is a real bounded context for cross-cutting infrastructure | |
||||
|
|
||||
|
### 10.2 Capability Folders (Action / DTO / Event / Listener subdirectories) |
||||
|
|
||||
|
Subdirectories inside `Actions/`, `DTOs/`, `Events/`, and `Listeners/` must be named after **Business Capabilities**, not database nouns. |
||||
|
|
||||
|
| ✅ Correct | ❌ Wrong | Why | |
||||
|
|---|---|---| |
||||
|
| `Onboarding/` | `Users/` | Describes the lifecycle stage, not the DB table | |
||||
|
| `AccessControl/` | `Roles/` | Describes the capability, not the resource | |
||||
|
| `Governance/` | `Admin/` | Describes the compliance intent | |
||||
|
| `Passwords/` | `Auth/` | Narrow, precise scope | |
||||
|
| `Registration/` | `Signup/` | Uses the system's formal language | |
||||
|
|
||||
|
**Rule:** If a folder name is also a valid Eloquent Model name, it is wrong. |
||||
|
|
||||
|
### 10.3 Action Class Names |
||||
|
|
||||
|
Actions must be named after the **specific Business Intent** they fulfill. Use an active verb + business noun pattern. |
||||
|
|
||||
|
| ✅ Correct | ❌ Wrong | Why | |
||||
|
|---|---|---| |
||||
|
| `ProvisionNewUser` | `CreateUser` | Describes *who* triggers it and *why* | |
||||
|
| `SuspendUser` | `DeleteUser` | Reveals the business consequence (soft revoke, not destroy) | |
||||
|
| `UpdateUserRole` | `SaveRole` | Explicit about the subject and property being changed | |
||||
|
| `RegisterUser` | `StoreUser` | Domain language, not HTTP verb language | |
||||
|
| `SendPasswordResetLink` | `ResetPassword` | Reflects the actual side effect triggered | |
||||
|
|
||||
|
CRUD names (`CreateCategory`, `UpdateSetting`) are only acceptable for trivial lookup tables with **no side effects**. |
||||
|
|
||||
|
### 10.4 DTO Class Names |
||||
|
|
||||
|
DTOs are named after the Action they serve, with a `DTO` suffix. |
||||
|
|
||||
|
| Action | DTO | |
||||
|
|---|---| |
||||
|
| `ProvisionNewUser` | `ProvisionUserDTO` | |
||||
|
| `UpdateUser` | `UpdateUserDTO` | |
||||
|
| `CreateSystemRole` | `CreateRoleDTO` | |
||||
|
|
||||
|
### 10.5 Event Class Names |
||||
|
|
||||
|
Events are **past-tense facts** about something that already happened in the domain. The class name must be grammatically a completed truth. |
||||
|
|
||||
|
| ✅ Correct | ❌ Wrong | Why | |
||||
|
|---|---|---| |
||||
|
| `UserWasProvisioned` | `UserProvisioned` | Explicit past-tense removes ambiguity | |
||||
|
| `UserWasSuspended` | `UserSuspended` | Reads as a state, not a completed fact | |
||||
|
| `UserLoggedIn` | `LoginEvent` | Noun + verb pattern; avoids the `Event` suffix | |
||||
|
| `UserEmailVerified` | `EmailVerification` | Describes the completed action | |
||||
|
|
||||
|
**Rule:** Never suffix Events with `Event` (e.g., `UserRegisteredEvent` is wrong). The namespace `Events\` already communicates the type. |
||||
|
|
||||
|
### 10.6 Listener Class Names |
||||
|
|
||||
|
Listeners describe the **active reaction** to an event using an imperative verb phrase. |
||||
|
|
||||
|
| ✅ Correct | ❌ Wrong | Why | |
||||
|
|---|---|---| |
||||
|
| `SendSignInActivityNotification` | `UserLoggedInListener` | Describes what the listener *does*, not what it reacts to | |
||||
|
| `DispatchWelcomeNotification` | `WelcomeListener` | Imperative verb makes the intent crystal clear | |
||||
|
|
||||
|
**Rule:** Never suffix Listeners with `Listener` in the class name. The namespace `Listeners\` already communicates the type. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 11. AI Agent Prompt (System Instructions) |
||||
|
|
||||
|
**For Developers:** Copy and paste the block below into your AI agent's chat or system instructions before asking it to write or refactor code in this repository. |
||||
|
|
||||
|
```text |
||||
|
You are an autonomous Senior Laravel Architect specializing in Pragmatic Domain-Driven Design (DDD) and Event-Driven Architecture. You must strictly obey the "Antigravity" rules of this repository. |
||||
|
|
||||
|
### 1. The HTTP Gateway (Delivery Layer) |
||||
|
- Lives in `app/Http/Controllers/`, `app/Http/DataTables/`, or `app/Livewire/`. |
||||
|
- Responsibilities: HTTP validation, rate limiting, session management (`Auth::login`, `session()->regenerate()`), and redirects. |
||||
|
- HARD RESTRICTION: The Gateway MUST NEVER call `Model::create()`, `Model::update()`, or `Hash::make()`. It must map validated data into a DTO and pass it to a Domain Action. |
||||
|
|
||||
|
### 2. The Domain (Business Logic) |
||||
|
- Lives inside `app/Domains/{Concept}/`. |
||||
|
- Responsibilities: DTO definitions, Actions (database writes), Models, and Events. |
||||
|
- HARD RESTRICTION: The Domain MUST NEVER read from the `request()` helper, manipulate cookies/sessions, or throw HTTP-specific exceptions (like `ValidationException`). |
||||
|
|
||||
|
### 3. Execution & Workflow |
||||
|
- DTOs: Must be strictly typed readonly classes. |
||||
|
- Actions: Must represent a specific Business Intent (e.g., `ProvisionNewUser` not `SaveUser`). Actions that perform multiple database writes must wrap them in a `DB::transaction()`. |
||||
|
- Action Composition: Inject Actions into other Actions via the constructor to reuse logic (e.g., injecting `AccessControl\UpdateUserRole` into `Onboarding\ProvisionNewUser`). |
||||
|
- Events: Use Events to decouple side effects (Notifications, Activity Logs). Events must be **past-tense** (e.g., `UserWasProvisioned` not `UserProvisioned`). |
||||
|
|
||||
|
### 4. Naming Rules |
||||
|
- **Domains**: Singular Business Concepts, never database nouns (✅ `Identity` ❌ `Users`). |
||||
|
- **Capability Folders**: Name subdirectories after business capabilities, never after models (✅ `Onboarding/`, `AccessControl/`, `Governance/` ❌ `Users/`, `Roles/`). |
||||
|
- **Actions**: Active verb + business noun (✅ `ProvisionNewUser` ❌ `CreateUser`). |
||||
|
- **Events**: Past-tense completed facts, no `Event` suffix (✅ `UserWasProvisioned` ❌ `UserProvisionedEvent`). |
||||
|
- **Listeners**: Imperative active-verb phrases, no `Listener` suffix (✅ `SendSignInActivityNotification` ❌ `UserLoggedInListener`). |
||||
|
|
||||
|
### 5. File Generation Rules |
||||
|
- NEVER use standard Laravel generators (e.g., `php artisan make:model`) for Domain classes. |
||||
|
- ALWAYS use the custom `domain:make` command to create Domain files. |
||||
|
- Example: `php artisan domain:make action Identity Onboarding/ProvisionNewUser` |
||||
|
- Supported types: `model`, `action`, `dto`, `enum`, `event`, `listener`, `notification`, `policy`, `query`, `provider`, `export`, `mapper`, `scope`, `trait`, `mailable`. |
||||
|
- Examples for the Integration layer: |
||||
|
- `php artisan domain:make export Identity UserExport --model=User` |
||||
|
- `php artisan domain:make mapper Identity User` → generates `Integration/Mappers/UserDataMapper.php` |
||||
|
- Excel ingestion (Import) classes live in the **Gateway layer** at `app/Http/Ingestion/Excel/` — do NOT generate them with `domain:make`. |
||||
|
- Queries: For complex database reads (e.g., massive filtering or reporting), create a Query class in app/Domains/{Concept}/Queries/. Queries are read-only, do not use transactions, do not mutate state, and do not dispatch events. |
||||
|
|
||||
|
### 6. Testing Strategy |
||||
|
- Always write tests using Pest PHP. |
||||
|
- When generating new features (Actions/DTOs/Models), create corresponding tests in `tests/Feature/` or `tests/Unit/`. |
||||
|
- Ensure 100% adherence to Architectural tests (check `tests/Architecture/` for existing rules). |
||||
|
- When writing tests, use `Event::fake()`, `Queue::fake()`, `Notification::fake()` to isolate side effects. |
||||
|
- For dependency-injected services, use `$this->mock()` to define expected behaviors. |
||||
|
|
||||
|
Write modern PHP 8.4+ code with strict typing. Ensure all PSR-4 namespaces perfectly match the directory structure. |
||||
|
|
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 12. Development Tools & Generators |
||||
|
|
||||
|
To maintain the strict folder structure of the Antigravity architecture, **do not use standard `make:` commands (like `make:model`) for Domain files.** Use the custom `domain:make` command to generate classes in the correct `app/Domains/` namespaces. |
||||
|
|
||||
|
### The `domain:make` Command |
||||
|
|
||||
|
**Signature:** |
||||
|
|
||||
|
```bash |
||||
|
php artisan domain:make {type} {domain} {name} [options] |
||||
|
|
||||
|
``` |
||||
|
|
||||
|
**Arguments:** |
||||
|
|
||||
|
* `type`: The file type to generate. Supported: `model`, `action`, `dto`, `enum`, `event`, `listener`, `notification`, `policy`, `scope`, `trait`, `query`, `provider`, `export`, `mapper`, `mailable`. |
||||
|
* `domain`: The target Domain folder (e.g., `Identity`, `Account`, `System`). |
||||
|
* `name`: The class name. Supports sub-directory grouping (e.g., `Management/ProvisionNewUser`). |
||||
|
|
||||
|
**Options:** |
||||
|
|
||||
|
* `--factory`: Generates an associated database factory (Models only). |
||||
|
* `--migration`: Generates a database migration file (Models only). |
||||
|
* `--model=`: Associates the export class with an Eloquent model (Exports only). |
||||
|
|
||||
|
### Customizing Generators (Stubs) |
||||
|
|
||||
|
All `domain:make` templates are stored as `.stub` files in `app/Console/stubs/domain-make/`. You can freely edit these stubs to customize the default boilerplate for your project's specific needs (e.g., changing the default methods in a Repository or adjusting the strict typing in a DTO). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 13. Universal File Management (The System Domain) |
||||
|
|
||||
|
File handling (uploads, attachments, image cropping, and deletions) is a universally shared capability. To prevent every domain from writing its own file storage logic, all physical files are managed by a centralized engine within the **`System`** domain. |
||||
|
|
||||
|
### The Polymorphic Engine |
||||
|
|
||||
|
We do not add file paths directly to business tables (e.g., no `avatar_path` column on the `users` table). Instead, we use a central `files` table and the `System\Models\File` model. |
||||
|
|
||||
|
* Files are attached **polymorphically** to any entity in the application. |
||||
|
* The `files` table includes a strictly typed `relation_name` string column (e.g., `'avatar'`) to prevent multiple file types attached to the same model from colliding. |
||||
|
|
||||
|
### The Consumer Trait |
||||
|
|
||||
|
When a Domain Model (like `User` or `Invoice`) needs to accept a file attachment, it pulls in the `HasFile` trait. This trait provides isolated relationship builders. |
||||
|
|
||||
|
```php |
||||
|
namespace App\Domains\Identity\Models; |
||||
|
|
||||
|
use Illuminate\Database\Eloquent\Model; |
||||
|
use App\Domains\System\Traits\HasFile; |
||||
|
use Illuminate\Database\Eloquent\Relations\MorphOne; |
||||
|
|
||||
|
class User extends Model |
||||
|
{ |
||||
|
use HasFile; |
||||
|
|
||||
|
// The string 'avatar' perfectly isolates this file in the database |
||||
|
public function avatar(): MorphOne |
||||
|
{ |
||||
|
return $this->singleFile('avatar'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
``` |
||||
|
|
||||
|
### File Actions (Metadata via DTO) |
||||
|
|
||||
|
Because Laravel's `Illuminate\Http\UploadedFile` is a complex object, we pass it alongside a strictly-typed `FileDTO` that contains the metadata (target model, disk, and relation name). This ensures the Gateway remains clean while the Domain receives all necessary context. |
||||
|
|
||||
|
* **`UploadAndAttachFile`**: The base action. It stores the physical file to the disk and creates the polymorphic database record. |
||||
|
* **`ReplaceSingleFile`**: Used for 1-to-1 replacements (like changing an avatar). It safely deletes the old file before delegating the new upload back to the base action. |
||||
|
|
||||
|
**Gateway Example:** |
||||
|
|
||||
|
```php |
||||
|
$action->execute( |
||||
|
newFile: $request->file('photo'), |
||||
|
dto: new FileDTO( |
||||
|
modelType: $user->getMorphClass(), |
||||
|
modelId: $user->id, |
||||
|
relationName: 'avatar', |
||||
|
disk: 'local', |
||||
|
directory: 'avatars', |
||||
|
options: [], |
||||
|
uploaderId: auth()->id(), |
||||
|
) |
||||
|
); |
||||
|
|
||||
|
``` |
||||
|
|
||||
|
### System Asset Helper |
||||
|
|
||||
|
To avoid polluting Laravel's global namespace with a junk `app/helpers.php` file, we maintain a strictly domain-bound helper file at `app/Domains/System/Helpers/asset.php`. It is autoloaded via `composer.json` and provides the `asset_static()` function to elegantly resolve public and private files in our Blade views. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 14. Global Settings & Application State |
||||
|
|
||||
|
Settings that dictate the runtime state of the application (Timezones, Localization, SEO tags) are managed by the `System` domain to ensure high performance and context awareness. |
||||
|
|
||||
|
* **Memoization & Singletons:** The `GetSystemSettings` query is registered as a Singleton in the `SystemServiceProvider`. It fetches data from the database/cache *once* and stores it in local PHP memory for the duration of the request. |
||||
|
* **Contextual Middlewares:** We use dedicated middlewares (`HandlePreferredLanguage`, `HandlePreferredTimezone`) to dynamically check the authenticated user's preferences, falling back to the global settings if no preference exists. |
||||
|
* **View Composers:** Global layout variables (like Logos and Web Names) are injected globally via View Composers in the Service Provider, preventing repetitive `@inject` directives. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 15. Audit Logging & Tracking |
||||
|
|
||||
|
All critical database mutations are tracked to maintain a compliant historical ledger. |
||||
|
|
||||
|
* **Model Auditing:** We utilize Eloquent events to automatically log row changes. |
||||
|
* **Complex Relation Auditing:** Tracking many-to-many relationship changes (like Spatie `syncPermissions`) bypasses standard Eloquent events. Therefore, we explicitly dispatch Custom Audit Events directly inside the relevant Domain Action (e.g., `UpdateRolePermissions`). This guarantees the "Before" and "After" state is captured cleanly in a single transactional row. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 16. Dynamic UIs & Livewire Interoperability |
||||
|
|
||||
|
When building data-driven interfaces (like dynamic settings forms), we utilize the **Renderable Enum** pattern combined with Laravel's native dynamic components. |
||||
|
|
||||
|
* Enums act as **Metadata Providers** (returning the string name of the target Blade component). |
||||
|
* We use `<x-dynamic-component>` in the Blade file to swap UI elements. |
||||
|
* **CRITICAL:** Enums must never return raw HTML strings compiled via the `Blade` facade. Doing so breaks Livewire's DOM-diffing engine and severs `wire:model` bindings. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 17. Excel Import & Export |
||||
|
|
||||
|
The Laravel component `resources/views/components/datatables/⚡excel-manager.blade.php` (registered as `<livewire:datatables.excel-manager>`) provides a reusable, queue-backed mechanism for importing and exporting Excel files in any DataTable page. It is a **unified single-file Laravel component** — PHP class logic and Blade template co-exist in the same file, following the `⚡` naming convention used across all Laravel components in this project. |
||||
|
|
||||
|
### Architecture Overview |
||||
|
|
||||
|
The component relies on three collaborating layers: |
||||
|
|
||||
|
1. **The Laravel Component** (`⚡excel-manager.blade.php`) — Handles UI state, file uploads via `WithFilePond`, validation, and Livewire event listeners. All props are secured with `#[Locked]` to prevent client-side tampering. |
||||
|
2. **The `StyledExport` Decorator** (`App\UI\Support\Excel\StyledExport`) — A UI-layer wrapper that enriches any domain Export with standardized visual styling (frozen header row, landscape orientation, thin borders, centered alignment, and auto-sized columns) without polluting domain Export classes with presentation logic. |
||||
|
3. **The Event-Driven Notification Pipeline** — After the export file is written to disk, a queued `NotifyExportReady` job dispatches the `ExportCompleted` event, which is handled by the `SendExportReportEmail` listener to deliver the file via email. |
||||
|
|
||||
|
### How It Works |
||||
|
|
||||
|
* **Import:** The user uploads an `.xlsx` file via the FilePond modal. The file is stored to `local/excel/import/{resourceName}` and a new import instance is constructed with a UUID (`$importId`) and the authenticated user's ID (`$initiatorId`) before being dispatched to the queue via `Excel::queueImport()`. The Ingestion class lives in `app/Http/Ingestion/` (Gateway layer) and implements `WithChunkReading` (chunk size: 200 rows) to stay within shared-hosting memory limits. A success toast (`ui.excel.import.success`) is shown immediately upon queuing. |
||||
|
|
||||
|
* **Export:** A Livewire browser event (`export-excel`) — dispatched by the DataTable's Export button — triggers the `export()` method via `#[On('export-excel')]`. The domain Export is wrapped in `StyledExport` and queued via `Excel::queue()`. The job chain appends `NotifyExportReady`, which dispatches `ExportCompleted`, which is handled by `SendExportReportEmail` to send the file as an email attachment to the authenticated user. A success toast (`ui.excel.export.success`) is shown immediately upon queuing. |
||||
|
|
||||
|
### Component Props |
||||
|
|
||||
|
| Prop | Type | Description | |
||||
|
| --- | --- | --- | |
||||
|
| `importClass` | `string` | Fully-qualified class name of the Gateway Ingestion class (e.g., `App\Http\Ingestion\Excel\Identity\UserImport`). | |
||||
|
| `exportClass` | `string` | Fully-qualified class name of the domain Export (e.g., `App\Domains\Identity\Exports\UserExport`). | |
||||
|
| `resourceName` | `string` | A slug used to name the stored import file and the timestamped export file (e.g., `user`). | |
||||
|
|
||||
|
### Usage |
||||
|
|
||||
|
Embed the component in any DataTable page view. All props must be provided as fully-qualified PHP class name strings: |
||||
|
|
||||
|
```blade |
||||
|
<livewire:datatables.excel-manager |
||||
|
:export-class="\App\Domains\Identity\Exports\UserExport::class" |
||||
|
:import-class="\App\Http\Ingestion\Excel\Identity\UserImport::class" |
||||
|
resource-name="user" |
||||
|
/> |
||||
|
``` |
||||
|
|
||||
|
The DataTable's Export button should dispatch the `export-excel` Livewire event, and the Import button should open the `#excel-import-modal` Bootstrap modal: |
||||
|
|
||||
|
```php |
||||
|
// In your DataTable html() builder: |
||||
|
Button::make('excel') |
||||
|
->action("Livewire.dispatch('export-excel')"), |
||||
|
|
||||
|
Button::make('excel') |
||||
|
->action("$('#excel-import-modal').modal('show')"), |
||||
|
``` |
||||
|
|
||||
|
### Generating Export & Mapper Classes |
||||
|
|
||||
|
Use the `domain:make` command to create Export and Integration layer classes: |
||||
|
|
||||
|
```bash |
||||
|
# Generate a domain Export class |
||||
|
php artisan domain:make export Identity UserExport --model=User |
||||
|
|
||||
|
# Generate an Integration Mapper (auto-appends DataMapper suffix) |
||||
|
php artisan domain:make mapper Identity User |
||||
|
# → app/Domains/Identity/Integration/Mappers/UserDataMapper.php |
||||
|
``` |
||||
|
|
||||
|
Domain Export classes must implement `FromQuery & WithHeadings & WithMapping & WithColumnFormatting`. The `StyledExport` decorator will apply all visual styling automatically at queue time — do **not** implement `WithStyles` directly on domain Exports. |
||||
|
|
||||
|
> **Gateway Layer:** Excel Ingestion (Import) classes live in `app/Http/Ingestion/Excel/` and are **not** generated by `domain:make`. Create them manually or with `make:class` as standard PHP classes implementing `ToCollection`, `WithHeadingRow`, and `WithChunkReading`. |
||||
|
|
||||
|
### The Notification Pipeline |
||||
|
|
||||
|
The export notification flow follows a strict, fully-queued event-driven chain: |
||||
|
|
||||
|
``` |
||||
|
Excel::queue(StyledExport, $path) |
||||
|
└─> NotifyExportReady (Job) [app/Domains/System/Jobs/] |
||||
|
└─> ExportCompleted::dispatch (Event) [app/Domains/System/Events/] |
||||
|
└─> SendExportReportEmail (Listener) [app/Domains/System/Listeners/] |
||||
|
└─> ExcelExportEmail (Mailable) [app/Domains/System/Mail/] |
||||
|
``` |
||||
|
|
||||
|
The import notification is sent by the domain Import class itself upon completion, using `ExcelImportEmail` from the same `App\Domains\System\Mail\` namespace. |
||||
|
|
||||
|
### Mailables |
||||
|
|
||||
|
Both Mailable classes live in the **System domain**, not the root `App\Mail\` namespace: |
||||
|
|
||||
|
* **`App\Domains\System\Mail\ExcelImportEmail`** — Sent when a queued import finishes. Uses `domains/system.notifications.excel.import_email.*` translations. |
||||
|
* **`App\Domains\Identity\Mail\Registration\WelcomeEmail`** — Example of a domain-specific mailable. |
||||
|
* **`App\Domains\System\Mail\ExcelExportEmail`** — Sent when a queued export is ready, with the file attached from the `local` disk. Uses `domains/system.notifications.excel.export_email.*` translations. |
||||
|
|
||||
|
### Translation Keys |
||||
|
|
||||
|
| File | Key | Purpose | |
||||
|
| --- | --- | --- | |
||||
|
| `lang/{locale}/ui.php` | `ui.excel.import.file_label` | FilePond upload label inside the import modal. | |
||||
|
| `lang/{locale}/ui.php` | `ui.excel.import.success` | Toast shown after import is queued. | |
||||
|
| `lang/{locale}/ui.php` | `ui.excel.export.success` | Toast shown after export is queued. | |
||||
|
| `lang/{locale}/domains/system.php` | `notifications.excel.import_email.*` | Email body for the import completion notification. | |
||||
|
| `lang/{locale}/domains/system.php` | `notifications.excel.export_email.*` | Email body for the export ready notification. | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 18. Testing |
||||
|
|
||||
|
We use [Pest PHP](https://pestphp.com) for our test suite. Please refer to [TESTING.md](TESTING.md) for detailed instructions on running, structuring, and writing tests. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 19. Environment Variables |
||||
|
|
||||
|
Key variables used in `.env`: |
||||
|
|
||||
|
- `APP_NAME`: Name of the application. |
||||
|
- `APP_ENV`: Application environment (`local`, `production`, etc.). |
||||
|
- `APP_KEY`: Application encryption key. |
||||
|
- `DB_CONNECTION`: Database driver (`sqlite`, `mysql`, `pgsql`). |
||||
|
- `QUEUE_CONNECTION`: Queue driver (default: `database`). |
||||
|
- `MAIL_MAILER`: Mail driver (default: `log`). |
||||
|
|
||||
|
See `.env.example` for the full list of available options. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 20. License |
||||
|
|
||||
|
This project is licensed under the **MIT License**. |
||||
|
|
||||
|
|
||||
@ -0,0 +1,125 @@ |
|||||
|
# Testing Guide |
||||
|
|
||||
|
We use [Pest PHP](https://pestphp.com) for our test suite, providing a clean, expressive syntax for testing our Modular Monolith. |
||||
|
|
||||
|
## 1. Running Tests |
||||
|
|
||||
|
The project includes several pre-configured commands to run the test suite: |
||||
|
|
||||
|
- **Run all tests:** `composer test` |
||||
|
- **Run Feature tests:** `php artisan test --testsuite=Feature` |
||||
|
- **Run Unit tests:** `php artisan test --testsuite=Unit` |
||||
|
- **Run Architecture tests:** `php artisan test --testsuite=Architecture` |
||||
|
|
||||
|
You can also run specific tests by passing arguments to Artisan: |
||||
|
|
||||
|
```bash |
||||
|
# Run a specific file |
||||
|
php artisan test tests/Feature/Identity/LoginTest.php |
||||
|
|
||||
|
# Run a specific test method |
||||
|
php artisan test --filter=test_user_can_login |
||||
|
``` |
||||
|
|
||||
|
## 2. Test Directory Structure |
||||
|
|
||||
|
Our tests follow a strict directory structure that mirrors the application's domain-based architecture: |
||||
|
|
||||
|
- `tests/Feature/`: High-level application features. These tests interact with the application like a user (HTTP requests, Livewire components, form submissions). |
||||
|
- `tests/Unit/`: Low-level logic, helpers, and domain-specific logic. These tests should be fast and have zero external dependencies. |
||||
|
- `tests/Architecture/`: Ensures the integrity of our Modular Monolith (e.g., verifying that the Domain layer does not depend on the HTTP layer). |
||||
|
|
||||
|
## 3. Writing Tests |
||||
|
|
||||
|
### Base Class |
||||
|
All tests extend `Tests\TestCase`. This base class sets up the application environment and includes shared traits. |
||||
|
|
||||
|
### Database Interaction |
||||
|
By default, Feature tests use the `RefreshDatabase` trait (configured in `tests/Pest.php`). This ensures a clean database state for every test. |
||||
|
|
||||
|
### Architectural Rules |
||||
|
We use architecture tests to prevent "spaghetti code." For example: |
||||
|
- **Layer Isolation:** The `Domains/` directory must not import anything from `Http/` or `Livewire/`. |
||||
|
- **Debugging:** Ensure `dd()` or `dump()` calls are not committed to the repository. |
||||
|
|
||||
|
### Writing Domain Tests |
||||
|
When testing a new Domain feature, create a corresponding test file in the `tests/Feature/` or `tests/Unit/` directory. |
||||
|
|
||||
|
Example: If you create `app/Domains/Identity/Actions/CreateUser.php`, create a test at `tests/Unit/Domains/Identity/Actions/CreateUserTest.php`. |
||||
|
|
||||
|
## 4. Best Practices |
||||
|
|
||||
|
- **Test Behavior, Not Implementation:** Focus on what the code does, not how it does it. |
||||
|
- **Keep Tests Fast:** Unit tests should run in milliseconds. If a test is slow, it might be a candidate for a unit test rather than a feature test. |
||||
|
- **Use Factories:** Utilize Laravel model factories to generate test data instead of manually creating records. |
||||
|
- **Mock External Services:** Use dependency injection and interfaces to mock external API calls or email services in your tests. |
||||
|
|
||||
|
## 5. Advanced Testing |
||||
|
|
||||
|
### Testing Events |
||||
|
To assert that events are dispatched, use Laravel's `Event` facade: |
||||
|
|
||||
|
```php |
||||
|
use App\Domains\Identity\Events\UserLoggedIn; |
||||
|
use Illuminate\Support\Facades\Event; |
||||
|
|
||||
|
it('dispatches the user logged in event', function () { |
||||
|
Event::fake(); |
||||
|
|
||||
|
// Perform action |
||||
|
$this->post('/login', [...]); |
||||
|
|
||||
|
Event::assertDispatched(UserLoggedIn::class); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
### Testing Jobs |
||||
|
To assert that jobs are pushed to the queue, use the `Queue` facade: |
||||
|
|
||||
|
```php |
||||
|
use App\Domains\System\Jobs\NotifyExportReady; |
||||
|
use Illuminate\Support\Facades\Queue; |
||||
|
|
||||
|
it('queues the export notification job', function () { |
||||
|
Queue::fake(); |
||||
|
|
||||
|
// Perform action that triggers the job |
||||
|
// ... |
||||
|
|
||||
|
Queue::assertPushed(NotifyExportReady::class); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
### Testing Notifications |
||||
|
To test notifications, use the `Notification` facade: |
||||
|
|
||||
|
```php |
||||
|
use App\Domains\Identity\Notifications\WelcomeNotification; |
||||
|
use Illuminate\Support\Facades\Notification; |
||||
|
|
||||
|
it('sends a welcome notification', function () { |
||||
|
Notification::fake(); |
||||
|
|
||||
|
// Perform action |
||||
|
// ... |
||||
|
|
||||
|
Notification::assertSentTo($user, WelcomeNotification::class); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
### Mocking |
||||
|
For services or classes that are dependency-injected, use `mock()` or `spy()`: |
||||
|
|
||||
|
```php |
||||
|
use App\Domains\System\Integration\ExternalApiService; |
||||
|
|
||||
|
it('uses the external api service', function () { |
||||
|
$this->mock(ExternalApiService::class, function ($mock) { |
||||
|
$mock->shouldReceive('call') |
||||
|
->once() |
||||
|
->andReturn(['status' => 'success']); |
||||
|
}); |
||||
|
|
||||
|
// Run code that uses ExternalApiService |
||||
|
}); |
||||
|
``` |
||||
@ -0,0 +1,15 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Attributes; |
||||
|
|
||||
|
use Attribute; |
||||
|
|
||||
|
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] |
||||
|
final readonly class LayoutData |
||||
|
{ |
||||
|
public function __construct( |
||||
|
public ?string $header = null, |
||||
|
public array $breadcrumbs = [], |
||||
|
public ?string $context = null, |
||||
|
) {} |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Attributes; |
||||
|
|
||||
|
use Attribute; |
||||
|
|
||||
|
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] |
||||
|
final readonly class Seo |
||||
|
{ |
||||
|
public function __construct( |
||||
|
public string $title, |
||||
|
public string $name = '', |
||||
|
public string $description = '', |
||||
|
public string|array $keywords = [], |
||||
|
public string $image = '', |
||||
|
public string $url = '', |
||||
|
public string $type = 'website', |
||||
|
public string $context = '', |
||||
|
) {} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Console\Commands; |
||||
|
|
||||
|
use App\Domains\System\Actions\Files\PruneOrphanedFiles; |
||||
|
use Illuminate\Console\Attributes\Description; |
||||
|
use Illuminate\Console\Attributes\Signature; |
||||
|
use Illuminate\Console\Command; |
||||
|
|
||||
|
#[Signature('system:prune-files {--directory=/} {--disk=public}')] |
||||
|
#[Description('Command description')] |
||||
|
class CleanOrphanedFiles extends Command |
||||
|
{ |
||||
|
/** |
||||
|
* Execute the console command. |
||||
|
*/ |
||||
|
public function handle(PruneOrphanedFiles $action): int |
||||
|
{ |
||||
|
$this->info('Starting file reconciliation...'); |
||||
|
|
||||
|
$stats = $action->execute( |
||||
|
disk: $this->option('disk'), |
||||
|
directory: $this->option('directory') |
||||
|
); |
||||
|
|
||||
|
$this->info('Reconciliation complete.'); |
||||
|
$this->line("- DB Orphans Removed: {$stats['db_orphans_removed']}"); |
||||
|
$this->line("- Disk Stranded Files Removed: {$stats['disk_orphans_removed']}"); |
||||
|
|
||||
|
return Command::SUCCESS; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,205 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Console\Commands; |
||||
|
|
||||
|
use Illuminate\Console\Command; |
||||
|
use Illuminate\Filesystem\Filesystem; |
||||
|
use Illuminate\Support\Str; |
||||
|
|
||||
|
class DomainMakeCommand extends Command |
||||
|
{ |
||||
|
protected $signature = 'domain:make |
||||
|
{type : Type to generate: model, action, dto, enum, event, listener, notification, policy, query, provider, export, mapper, scope, mailable} |
||||
|
{domain : Domain name, e.g. Identity, Account, System} |
||||
|
{name : Class name, supports sub-paths e.g. Backup/DeleteBackup} |
||||
|
{--factory : Also generate a factory (model only)} |
||||
|
{--migration : Also generate a migration (model only)} |
||||
|
{--model= : Associate the export with a model}'; |
||||
|
|
||||
|
protected $description = 'Generate a file directly into the domain structure (app/Domains/)'; |
||||
|
|
||||
|
/** @var array<string, string> type → subdirectory */ |
||||
|
protected array $types = [ |
||||
|
'model' => 'Models', |
||||
|
'action' => 'Actions', |
||||
|
'dto' => 'DTOs', |
||||
|
'enum' => 'Enums', |
||||
|
'event' => 'Events', |
||||
|
'listener' => 'Listeners', |
||||
|
'notification' => 'Notifications', |
||||
|
'policy' => 'Policies', |
||||
|
'scope' => 'Scopes', |
||||
|
'trait' => 'Traits', |
||||
|
'query' => 'Queries', |
||||
|
'provider' => 'Providers', |
||||
|
'export' => 'Exports', |
||||
|
// Integration layer — files live under Integration/<subDir>/ |
||||
|
'mapper' => 'Integration/Mappers', |
||||
|
'mailable' => 'Mail', |
||||
|
]; |
||||
|
|
||||
|
public function __construct(protected Filesystem $files) |
||||
|
{ |
||||
|
parent::__construct(); |
||||
|
} |
||||
|
|
||||
|
public function handle(): int |
||||
|
{ |
||||
|
$type = strtolower($this->argument('type')); |
||||
|
$domain = ucfirst($this->argument('domain')); |
||||
|
$name = $this->argument('name'); // may contain sub-path, e.g. Backup/DeleteBackup |
||||
|
|
||||
|
if (! isset($this->types[$type])) { |
||||
|
$this->components->error("Unknown type [{$type}]. Supported: ".implode(', ', array_keys($this->types))); |
||||
|
|
||||
|
return self::FAILURE; |
||||
|
} |
||||
|
|
||||
|
$subDir = $this->types[$type]; |
||||
|
$className = class_basename(str_replace('/', '\\', $name)); |
||||
|
$subPath = str_contains($name, '/') ? dirname($name) : null; |
||||
|
|
||||
|
// ── Integration / Mapper special handling ───────────────────────────── |
||||
|
// Automatically append the 'DataMapper' suffix when the developer omits it, |
||||
|
// keeping the class name consistent with the DataPayloadMapper contract. |
||||
|
if ($type === 'mapper' && ! str_ends_with($className, 'DataMapper')) { |
||||
|
$className .= 'DataMapper'; |
||||
|
} |
||||
|
|
||||
|
// The $subDir for 'mapper' already encodes the full Integration/Mappers |
||||
|
// nested path, so we must not double-nest an additional subPath beneath it. |
||||
|
// Extra sub-path segments are intentionally ignored for the mapper type. |
||||
|
$relativeDir = ($type === 'mapper') |
||||
|
? "Domains/{$domain}/{$subDir}" |
||||
|
: "Domains/{$domain}/{$subDir}".($subPath ? "/{$subPath}" : ''); |
||||
|
// ───────────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
$namespace = 'App\\'.str_replace('/', '\\', $relativeDir); |
||||
|
$path = app_path("{$relativeDir}/{$className}.php"); |
||||
|
|
||||
|
if ($this->files->exists($path)) { |
||||
|
$this->components->error("File already exists: [{$path}]"); |
||||
|
|
||||
|
return self::FAILURE; |
||||
|
} |
||||
|
|
||||
|
$this->files->ensureDirectoryExists(dirname($path)); |
||||
|
$this->files->put($path, $this->buildStub($type, $namespace, $className, $domain)); |
||||
|
|
||||
|
$this->components->info("File [{$path}] created successfully."); |
||||
|
|
||||
|
if ($type === 'model' && $this->option('factory')) { |
||||
|
$this->createFactory($domain, $className, $namespace); |
||||
|
} |
||||
|
|
||||
|
if ($type === 'model' && $this->option('migration')) { |
||||
|
$table = Str::snake(Str::pluralStudly($className)); |
||||
|
$this->call('make:migration', ['name' => "create_{$table}_table"]); |
||||
|
} |
||||
|
|
||||
|
return self::SUCCESS; |
||||
|
} |
||||
|
|
||||
|
// ─── Stubs ──────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
protected function buildStub(string $type, string $namespace, string $name, string $domain): string |
||||
|
{ |
||||
|
$stubPath = app_path("Console/stubs/domain-make/{$type}.stub"); |
||||
|
|
||||
|
if (! $this->files->exists($stubPath)) { |
||||
|
$this->components->error("Stub file not found: [{$stubPath}]"); |
||||
|
|
||||
|
return ''; |
||||
|
} |
||||
|
|
||||
|
$stub = $this->files->get($stubPath); |
||||
|
|
||||
|
$replacements = [ |
||||
|
'{{ namespace }}' => $namespace, |
||||
|
'{{ class }}' => $name, |
||||
|
]; |
||||
|
|
||||
|
if ($type === 'model') { |
||||
|
$replacements['{{ factoryImport }}'] = $this->option('factory') |
||||
|
? "\nuse Database\\Factories\\{$domain}\\{$name}Factory;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;" |
||||
|
: ''; |
||||
|
$replacements['{{ factoryMethod }}'] = $this->option('factory') |
||||
|
? "\n protected static function newFactory(): Factory\n {\n return {$name}Factory::new();\n }" |
||||
|
: ''; |
||||
|
} |
||||
|
|
||||
|
if ($type === 'export') { |
||||
|
$modelOption = $this->option('model'); |
||||
|
$modelImport = ''; |
||||
|
$queryBody = ' // return YourModel::query();'; |
||||
|
|
||||
|
if ($modelOption) { |
||||
|
$modelData = $this->resolveModel($modelOption, $domain); |
||||
|
$modelImport = "use {$modelData['full']};\n"; |
||||
|
$modelClass = $modelData['class']; |
||||
|
$queryBody = <<<PHP |
||||
|
return {$modelClass}::query() |
||||
|
// ->with('profile') // CRITICAL: Eager load any relations used in map() |
||||
|
// ->when(isset(\$this->filters['status']), fn(Builder \$q) => \$q->where('status', \$this->filters['status'])) |
||||
|
; |
||||
|
PHP; |
||||
|
} |
||||
|
|
||||
|
$replacements['{{ modelImport }}'] = $modelImport; |
||||
|
$replacements['{{ queryBody }}'] = $queryBody; |
||||
|
} |
||||
|
|
||||
|
return str_replace(array_keys($replacements), array_values($replacements), $stub); |
||||
|
} |
||||
|
|
||||
|
protected function resolveModel(string $modelOption, string $domain): array |
||||
|
{ |
||||
|
if (Str::contains($modelOption, ['\\', '/'])) { |
||||
|
if (Str::startsWith(str_replace('/', '\\', $modelOption), 'App\\')) { |
||||
|
$full = str_replace('/', '\\', $modelOption); |
||||
|
$class = class_basename($full); |
||||
|
} else { |
||||
|
$parts = explode('/', str_replace('\\', '/', $modelOption)); |
||||
|
$targetDomain = ucfirst($parts[0]); |
||||
|
$targetName = implode('\\', array_map('ucfirst', array_slice($parts, 1))); |
||||
|
$full = "App\\Domains\\{$targetDomain}\\Models\\{$targetName}"; |
||||
|
$class = class_basename($full); |
||||
|
} |
||||
|
} else { |
||||
|
$full = "App\\Domains\\{$domain}\\Models\\".ucfirst($modelOption); |
||||
|
$class = ucfirst($modelOption); |
||||
|
} |
||||
|
|
||||
|
return [ |
||||
|
'full' => $full, |
||||
|
'class' => $class, |
||||
|
]; |
||||
|
} |
||||
|
|
||||
|
protected function createFactory(string $domain, string $name, string $modelNamespace): void |
||||
|
{ |
||||
|
$factoryNamespace = "Database\\Factories\\{$domain}"; |
||||
|
$factoryPath = database_path("factories/{$domain}/{$name}Factory.php"); |
||||
|
|
||||
|
$this->files->ensureDirectoryExists(dirname($factoryPath)); |
||||
|
|
||||
|
if ($this->files->exists($factoryPath)) { |
||||
|
$this->components->warn("Factory already exists: [{$factoryPath}]"); |
||||
|
|
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
$stubPath = app_path('Console/stubs/domain-make/factory.stub'); |
||||
|
$stub = $this->files->exists($stubPath) ? $this->files->get($stubPath) : ''; |
||||
|
|
||||
|
$stub = str_replace( |
||||
|
['{{ factoryNamespace }}', '{{ modelNamespace }}', '{{ class }}'], |
||||
|
[$factoryNamespace, $modelNamespace, $name], |
||||
|
$stub |
||||
|
); |
||||
|
|
||||
|
$this->files->put($factoryPath, $stub); |
||||
|
|
||||
|
$this->components->info("Factory [database/factories/{$domain}/{$name}Factory.php] created successfully."); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace {{ namespace }}; |
||||
|
|
||||
|
class {{ class }} |
||||
|
{ |
||||
|
public function execute(): void |
||||
|
{ |
||||
|
// |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,10 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace {{ namespace }}; |
||||
|
|
||||
|
readonly class {{ class }} |
||||
|
{ |
||||
|
public function __construct( |
||||
|
// |
||||
|
) {} |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace {{ namespace }}; |
||||
|
|
||||
|
enum {{ class }}: string |
||||
|
{ |
||||
|
// |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace {{ namespace }}; |
||||
|
|
||||
|
use Illuminate\Foundation\Events\Dispatchable; |
||||
|
use Illuminate\Queue\SerializesModels; |
||||
|
|
||||
|
class {{ class }} |
||||
|
{ |
||||
|
|
||||
|
use Dispatchable, SerializesModels; |
||||
|
public function __construct( |
||||
|
// |
||||
|
) {} |
||||
|
} |
||||
@ -0,0 +1,80 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace {{ namespace }}; |
||||
|
|
||||
|
use Illuminate\Database\Eloquent\Builder; |
||||
|
use Maatwebsite\Excel\Concerns\FromQuery; |
||||
|
use Maatwebsite\Excel\Concerns\Exportable; |
||||
|
use Maatwebsite\Excel\Concerns\WithHeadings; |
||||
|
use Maatwebsite\Excel\Concerns\WithMapping; |
||||
|
use Maatwebsite\Excel\Concerns\WithStyles; |
||||
|
use Maatwebsite\Excel\Concerns\WithColumnFormatting; |
||||
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; |
||||
|
use PhpOffice\PhpSpreadsheet\Style\NumberFormat; |
||||
|
{{ modelImport }} |
||||
|
class {{ class }} implements FromQuery, WithHeadings, WithMapping, WithStyles, WithColumnFormatting |
||||
|
{ |
||||
|
use Exportable; |
||||
|
|
||||
|
// Accept UI filters directly into the Export class |
||||
|
public function __construct( |
||||
|
private array $filters = [] |
||||
|
) {} |
||||
|
|
||||
|
/** |
||||
|
* We use FromQuery instead of FromCollection to allow chunking. |
||||
|
*/ |
||||
|
public function query(): Builder |
||||
|
{ |
||||
|
{{ queryBody }} |
||||
|
} |
||||
|
|
||||
|
|
||||
|
public function headings(): array |
||||
|
{ |
||||
|
return [ |
||||
|
'ID', |
||||
|
'Created At', |
||||
|
'Amount', // Example column for currency |
||||
|
]; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* @param mixed $row |
||||
|
*/ |
||||
|
public function map($row): array |
||||
|
{ |
||||
|
return [ |
||||
|
$row->id, |
||||
|
// To format as a true Excel date, pass a native PHP DateTime object or an Excel timestamp |
||||
|
// \PhpOffice\PhpSpreadsheet\Shared\Date::dateTimeToExcel($row->created_at), |
||||
|
$row->created_at?->format('Y-m-d H:i'), |
||||
|
// $row->amount, |
||||
|
]; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Apply Excel formatting to specific columns (Dates, Currency, Percentages). |
||||
|
*/ |
||||
|
public function columnFormats(): array |
||||
|
{ |
||||
|
return [ |
||||
|
// Example: Format Column B as a true Date |
||||
|
// 'B' => NumberFormat::FORMAT_DATE_YYYYMMDD, |
||||
|
|
||||
|
// Example: Format Column C as Currency |
||||
|
// 'C' => NumberFormat::FORMAT_CURRENCY_USD_SIMPLE, |
||||
|
]; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Apply visual styling to specific rows or columns. |
||||
|
*/ |
||||
|
public function styles(Worksheet $sheet) |
||||
|
{ |
||||
|
return [ |
||||
|
// Make the first row (the headings) bold |
||||
|
1 => ['font' => ['bold' => true]], |
||||
|
]; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace {{ factoryNamespace }}; |
||||
|
|
||||
|
use {{ modelNamespace }}\{{ class }}; |
||||
|
use Illuminate\Database\Eloquent\Factories\Factory; |
||||
|
|
||||
|
/** |
||||
|
* @extends Factory<{{ class }}> |
||||
|
*/ |
||||
|
class {{ class }}Factory extends Factory |
||||
|
{ |
||||
|
protected $model = {{ class }}::class; |
||||
|
|
||||
|
public function definition(): array |
||||
|
{ |
||||
|
return []; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace {{ namespace }}; |
||||
|
|
||||
|
class {{ class }} |
||||
|
{ |
||||
|
public function handle(object $event): void |
||||
|
{ |
||||
|
// |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,53 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace {{ namespace }}; |
||||
|
|
||||
|
use Illuminate\Bus\Queueable; |
||||
|
use Illuminate\Contracts\Queue\ShouldQueue; |
||||
|
use Illuminate\Mail\Mailable; |
||||
|
use Illuminate\Mail\Mailables\Content; |
||||
|
use Illuminate\Mail\Mailables\Envelope; |
||||
|
use Illuminate\Queue\SerializesModels; |
||||
|
|
||||
|
class {{ class }} extends Mailable |
||||
|
{ |
||||
|
use Queueable, SerializesModels; |
||||
|
|
||||
|
/** |
||||
|
* Create a new message instance. |
||||
|
*/ |
||||
|
public function __construct() |
||||
|
{ |
||||
|
// |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get the message envelope. |
||||
|
*/ |
||||
|
public function envelope(): Envelope |
||||
|
{ |
||||
|
return new Envelope( |
||||
|
subject: '{{ class }}', |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get the message content definition. |
||||
|
*/ |
||||
|
public function content(): Content |
||||
|
{ |
||||
|
return new Content( |
||||
|
view: 'view.name', |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get the attachments for the message. |
||||
|
* |
||||
|
* @return array<int, \Illuminate\Mail\Mailables\Attachment> |
||||
|
*/ |
||||
|
public function attachments(): array |
||||
|
{ |
||||
|
return []; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,31 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace {{ namespace }}; |
||||
|
|
||||
|
use Illuminate\Database\Eloquent\Model; |
||||
|
use App\Domains\System\Support\Integration\DataPayloadMapper; |
||||
|
|
||||
|
class {{ class }} implements DataPayloadMapper |
||||
|
{ |
||||
|
public function __construct() |
||||
|
{ |
||||
|
// Inject required Domain and Cross-Domain Actions via constructor composition |
||||
|
} |
||||
|
|
||||
|
public function getLookupKey(): string |
||||
|
{ |
||||
|
// Return the unique string key used to identify existing records (e.g., 'email') |
||||
|
return 'id'; |
||||
|
} |
||||
|
|
||||
|
public function transform(array $rawData): array |
||||
|
{ |
||||
|
// Normalize incoming data array formats into an internal domain-safe layout |
||||
|
return $rawData; |
||||
|
} |
||||
|
|
||||
|
public function updateOrCreateDomainState(array $payload, ?Model $existingModel = null): void |
||||
|
{ |
||||
|
// Allocate mapped payloads into strict DTOs and fire internal/external Domain Actions |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace {{ namespace }}; |
||||
|
{{ factoryImport }} |
||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory; |
||||
|
use Illuminate\Database\Eloquent\Model; |
||||
|
|
||||
|
class {{ class }} extends Model |
||||
|
{ |
||||
|
use HasFactory; |
||||
|
{{ factoryMethod }} |
||||
|
} |
||||
@ -0,0 +1,27 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace {{ namespace }}; |
||||
|
|
||||
|
use Illuminate\Bus\Queueable; |
||||
|
use Illuminate\Notifications\Messages\MailMessage; |
||||
|
use Illuminate\Notifications\Notification; |
||||
|
|
||||
|
class {{ class }} extends Notification |
||||
|
{ |
||||
|
use Queueable; |
||||
|
|
||||
|
public function via(object $notifiable): array |
||||
|
{ |
||||
|
return ['mail']; |
||||
|
} |
||||
|
|
||||
|
public function toMail(object $notifiable): MailMessage |
||||
|
{ |
||||
|
return (new MailMessage)->line(''); |
||||
|
} |
||||
|
|
||||
|
public function toArray(object $notifiable): array |
||||
|
{ |
||||
|
return []; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,33 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace {{ namespace }}; |
||||
|
|
||||
|
use App\Domains\Identity\Models\User; |
||||
|
|
||||
|
class {{ class }} |
||||
|
{ |
||||
|
public function viewAny(User $user): bool |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
public function view(User $user, mixed $model): bool |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
public function create(User $user): bool |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
public function update(User $user, mixed $model): bool |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
public function delete(User $user, mixed $model): bool |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace {{ namespace }}; |
||||
|
|
||||
|
use Illuminate\Support\ServiceProvider; |
||||
|
|
||||
|
class {{ class }} extends ServiceProvider |
||||
|
{ |
||||
|
public static array $listen = []; |
||||
|
|
||||
|
public function register(): void |
||||
|
{ |
||||
|
// |
||||
|
} |
||||
|
|
||||
|
public function boot(): void |
||||
|
{ |
||||
|
// |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace {{ namespace }}; |
||||
|
|
||||
|
use Illuminate\Database\Eloquent\Collection; |
||||
|
|
||||
|
class {{ class }} |
||||
|
{ |
||||
|
public function fetch(): Collection |
||||
|
{ |
||||
|
return new Collection(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace {{ namespace }}; |
||||
|
|
||||
|
use Illuminate\Database\Eloquent\Builder; |
||||
|
use Illuminate\Database\Eloquent\Model; |
||||
|
use Illuminate\Database\Eloquent\Scope; |
||||
|
|
||||
|
class {{ class }} implements Scope |
||||
|
{ |
||||
|
public function apply(Builder $builder, Model $model): void |
||||
|
{ |
||||
|
// |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace {{ namespace }}; |
||||
|
|
||||
|
trait {{ class }} |
||||
|
{ |
||||
|
// |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Account\Actions\Profile; |
||||
|
|
||||
|
use App\Domains\Account\DTOs\Profile\UpdateProfileDTO; |
||||
|
use App\Domains\Account\Models\Profile; |
||||
|
|
||||
|
class UpdateProfile |
||||
|
{ |
||||
|
public function execute(UpdateProfileDTO $dto): void |
||||
|
{ |
||||
|
Profile::updateOrCreate( |
||||
|
['user_id' => $dto->userId], |
||||
|
[ |
||||
|
'gender' => $dto->gender, |
||||
|
'date_of_birth' => $dto->dateOfBirth, |
||||
|
'phone_number' => $dto->phoneNumber, |
||||
|
] |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Account\DTOs\Profile; |
||||
|
|
||||
|
use App\Domains\Account\Enums\GenderOption; |
||||
|
|
||||
|
readonly class UpdateProfileDTO |
||||
|
{ |
||||
|
public function __construct( |
||||
|
public string $userId, |
||||
|
public GenderOption $gender, |
||||
|
public string $dateOfBirth, |
||||
|
public string $phoneNumber, |
||||
|
) {} |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Account\Enums; |
||||
|
|
||||
|
enum GenderOption: string |
||||
|
{ |
||||
|
case MALE = 'male'; |
||||
|
case FEMALE = 'female'; |
||||
|
|
||||
|
public function label(): string |
||||
|
{ |
||||
|
return __("domains/account/enum.gender.{$this->value}"); |
||||
|
} |
||||
|
|
||||
|
public static function fromLabel($value): self |
||||
|
{ |
||||
|
return match ($value) { |
||||
|
self::MALE->label() => self::MALE, |
||||
|
self::FEMALE->label() => self::FEMALE, |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Account\Listeners; |
||||
|
|
||||
|
use App\Domains\Account\Actions\Profile\UpdateProfile; |
||||
|
use App\Domains\Account\DTOs\Profile\UpdateProfileDTO; |
||||
|
use App\Domains\Account\Enums\GenderOption; |
||||
|
use App\Domains\Identity\Events\Integration\UserImportWasProcessed; |
||||
|
|
||||
|
class SyncImportedUserProfile |
||||
|
{ |
||||
|
public function __construct( |
||||
|
protected UpdateProfile $updateProfile |
||||
|
) {} |
||||
|
|
||||
|
public function handle(UserImportWasProcessed $event): void |
||||
|
{ |
||||
|
$this->updateProfile->execute(new UpdateProfileDTO( |
||||
|
userId: $event->userId, |
||||
|
gender: GenderOption::fromLabel($event->gender), |
||||
|
dateOfBirth: $event->dateOfBirth, |
||||
|
phoneNumber: $event->phoneNumber, |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,37 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Account\Models; |
||||
|
|
||||
|
use App\Domains\Account\Enums\GenderOption; |
||||
|
use App\Domains\Identity\Models\User; |
||||
|
use Database\Factories\Account\ProfileFactory; |
||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable; |
||||
|
use Illuminate\Database\Eloquent\Factories\Factory; |
||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory; |
||||
|
use Illuminate\Database\Eloquent\Model; |
||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo; |
||||
|
|
||||
|
#[Fillable(['user_id', 'gender', 'date_of_birth', 'phone_number'])] |
||||
|
class Profile extends Model |
||||
|
{ |
||||
|
/** @use HasFactory<ProfileFactory> */ |
||||
|
use HasFactory; |
||||
|
|
||||
|
protected static function newFactory(): Factory |
||||
|
{ |
||||
|
return ProfileFactory::new(); |
||||
|
} |
||||
|
|
||||
|
protected function casts(): array |
||||
|
{ |
||||
|
return [ |
||||
|
'gender' => GenderOption::class, |
||||
|
'date_of_birth' => 'date', |
||||
|
]; |
||||
|
} |
||||
|
|
||||
|
public function user(): BelongsTo |
||||
|
{ |
||||
|
return $this->belongsTo(User::class); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Account\Providers; |
||||
|
|
||||
|
use App\Domains\Account\Listeners\SyncImportedUserProfile; |
||||
|
use App\Domains\Identity\Events\Integration\UserImportWasProcessed; |
||||
|
use Illuminate\Support\Facades\Event; |
||||
|
use Illuminate\Support\ServiceProvider; |
||||
|
|
||||
|
class AccountServiceProvider extends ServiceProvider |
||||
|
{ |
||||
|
public static array $listen = [ |
||||
|
UserImportWasProcessed::class => [ |
||||
|
SyncImportedUserProfile::class, |
||||
|
] |
||||
|
]; |
||||
|
|
||||
|
public function boot(): void |
||||
|
{ |
||||
|
// |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,26 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Identity\Actions\AccessControl; |
||||
|
|
||||
|
use App\Domains\Identity\DTOs\AccessControl\CreateRoleDTO; |
||||
|
use App\Domains\Identity\Models\Role; |
||||
|
use Illuminate\Support\Facades\DB; |
||||
|
use Throwable; |
||||
|
|
||||
|
class CreateSystemRole |
||||
|
{ |
||||
|
/** |
||||
|
* @throws Throwable |
||||
|
*/ |
||||
|
public function execute(CreateRoleDTO $dto): bool |
||||
|
{ |
||||
|
return DB::transaction(function () use ($dto) { |
||||
|
$role = Role::create([ |
||||
|
'name' => $dto->name, |
||||
|
]); |
||||
|
$role->syncPermissions($dto->permissions); |
||||
|
|
||||
|
return true; |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,26 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Identity\Actions\AccessControl; |
||||
|
|
||||
|
use App\Domains\Identity\Enums\RoleType; |
||||
|
use App\Domains\Identity\Models\Role; |
||||
|
use Exception; |
||||
|
|
||||
|
use function in_array; |
||||
|
|
||||
|
class RemoveSystemRole |
||||
|
{ |
||||
|
/** |
||||
|
* @throws Exception |
||||
|
*/ |
||||
|
public function execute(Role $role): void |
||||
|
{ |
||||
|
if (in_array($role->name, [RoleType::ADMIN->value, RoleType::SYSTEM_ADMIN->value])) { |
||||
|
throw new Exception(__('domains/identity/messages.exceptions.cannot_remove_system_role')); |
||||
|
} |
||||
|
|
||||
|
if ($role->users()->exists()) { |
||||
|
throw new Exception(__('domains/identity/messages.exceptions.role_has_users')); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,41 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Identity\Actions\AccessControl; |
||||
|
|
||||
|
use App\Domains\Identity\DTOs\AccessControl\UpdateRoleDTO; |
||||
|
use Illuminate\Support\Facades\DB; |
||||
|
use OwenIt\Auditing\Contracts\Auditable; |
||||
|
use OwenIt\Auditing\Events\AuditCustom; |
||||
|
use Spatie\Permission\Contracts\Role; |
||||
|
use Throwable; |
||||
|
|
||||
|
class UpdateSystemRole |
||||
|
{ |
||||
|
/** |
||||
|
* @throws Throwable |
||||
|
*/ |
||||
|
public function execute(Role $role, UpdateRoleDTO $dto): bool |
||||
|
{ |
||||
|
$oldPermissions = $role->permissions->pluck('name')->toArray(); |
||||
|
DB::transaction(function () use ($role, $dto) { |
||||
|
$role->syncPermissions($dto->permissions); |
||||
|
}); |
||||
|
|
||||
|
$role->load('permissions'); |
||||
|
$newPermissions = $role->permissions->pluck('name')->toArray(); |
||||
|
|
||||
|
if ($newPermissions !== $oldPermissions) { |
||||
|
/** @var Auditable $role */ |
||||
|
|
||||
|
// Tell Owen-It exactly what to log |
||||
|
$role->auditEvent = 'permissions_synced'; |
||||
|
$role->isCustomEvent = true; |
||||
|
$role->auditCustomOld = ['permissions' => implode(', ', $oldPermissions)]; |
||||
|
$role->auditCustomNew = ['permissions' => implode(', ', $newPermissions)]; |
||||
|
|
||||
|
event(new AuditCustom($role)); |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Identity\Actions\AccessControl; |
||||
|
|
||||
|
use App\Domains\Identity\Models\User; |
||||
|
|
||||
|
class UpdateUserRole |
||||
|
{ |
||||
|
public function execute(User $user, array $roles): void |
||||
|
{ |
||||
|
$user->syncRoles($roles); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,27 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Identity\Actions\Governance; |
||||
|
|
||||
|
use App\Domains\Identity\Enums\UserStatus; |
||||
|
use App\Domains\Identity\Events\Governance\UserWasActivated; |
||||
|
use App\Domains\Identity\Models\User; |
||||
|
use Exception; |
||||
|
|
||||
|
class ActivateUserStatus |
||||
|
{ |
||||
|
public function __construct(protected UpdateUserStatus $updateUserStatus) {} |
||||
|
|
||||
|
/** |
||||
|
* @throws Exception |
||||
|
*/ |
||||
|
public function execute(User $user): void |
||||
|
{ |
||||
|
if ($user->status->isActive()) { |
||||
|
throw new Exception(__('domains/identity/messages.exceptions.user_already_active')); |
||||
|
} |
||||
|
|
||||
|
$this->updateUserStatus->execute($user, UserStatus::ACTIVE); |
||||
|
|
||||
|
UserWasActivated::dispatch(email: $user->email); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Identity\Actions\Governance; |
||||
|
|
||||
|
use App\Domains\Identity\Enums\RoleType; |
||||
|
use App\Domains\Identity\Events\Governance\UserWasPurged; |
||||
|
use App\Domains\Identity\Models\User; |
||||
|
use Exception; |
||||
|
|
||||
|
class PurgeUser |
||||
|
{ |
||||
|
/** |
||||
|
* @throws Exception |
||||
|
*/ |
||||
|
public function execute(User $user): void |
||||
|
{ |
||||
|
if ($user->hasRole([RoleType::SYSTEM_ADMIN, RoleType::ADMIN])) { |
||||
|
throw new Exception(__('domains/identity/messages.exceptions.user_cannot_be_purged')); |
||||
|
} |
||||
|
|
||||
|
$user->delete(); |
||||
|
UserWasPurged::dispatch(email: $user->email, user_id: $user->id, model: User::class); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Identity\Actions\Governance; |
||||
|
|
||||
|
use App\Domains\Identity\Models\User; |
||||
|
|
||||
|
class RemoveUser |
||||
|
{ |
||||
|
public function __construct( |
||||
|
protected PurgeUser $purgeUser, |
||||
|
protected SuspendUser $suspendUser |
||||
|
) {} |
||||
|
|
||||
|
public function execute(User $user): void |
||||
|
{ |
||||
|
if ($user->status->isActive()) { |
||||
|
$this->suspendUser->execute($user); |
||||
|
} else { |
||||
|
$this->purgeUser->execute($user); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,36 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Identity\Actions\Governance; |
||||
|
|
||||
|
use App\Domains\Identity\Enums\RoleType; |
||||
|
use App\Domains\Identity\Enums\UserStatus; |
||||
|
use App\Domains\Identity\Events\Governance\UserWasSuspended; |
||||
|
use App\Domains\Identity\Models\User; |
||||
|
use Exception; |
||||
|
use Illuminate\Support\Facades\DB; |
||||
|
|
||||
|
class SuspendUser |
||||
|
{ |
||||
|
public function __construct(protected UpdateUserStatus $action) {} |
||||
|
|
||||
|
/** |
||||
|
* @throws Exception |
||||
|
*/ |
||||
|
public function execute(User $user): void |
||||
|
{ |
||||
|
if ($user->hasRole([RoleType::SYSTEM_ADMIN, RoleType::ADMIN])) { |
||||
|
throw new Exception(__('domains/identity/messages.exceptions.user_cannot_be_suspended')); |
||||
|
} |
||||
|
|
||||
|
if (! $user->status->isActive()) { |
||||
|
throw new Exception(__('domains/identity/messages.exceptions.user_already_suspended')); |
||||
|
} |
||||
|
|
||||
|
DB::table('sessions') |
||||
|
->where('user_id', $user->id) |
||||
|
->delete(); |
||||
|
|
||||
|
$this->action->execute($user, UserStatus::INACTIVE); |
||||
|
UserWasSuspended::dispatch(email: $user->email); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,27 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Identity\Actions\Governance; |
||||
|
|
||||
|
use App\Domains\Identity\Enums\RoleType; |
||||
|
use App\Domains\Identity\Enums\UserStatus; |
||||
|
use App\Domains\Identity\Models\User; |
||||
|
use Exception; |
||||
|
|
||||
|
class UpdateUserStatus |
||||
|
{ |
||||
|
/** |
||||
|
* @throws Exception |
||||
|
*/ |
||||
|
public function execute(User $user, UserStatus $status): void |
||||
|
{ |
||||
|
if ($user->status === $status) { |
||||
|
throw new Exception(__('domains/identity/messages.exceptions.user_already_status', ['status' => $status->value])); |
||||
|
} |
||||
|
|
||||
|
if ($user->hasRole([RoleType::SYSTEM_ADMIN, RoleType::ADMIN])) { |
||||
|
throw new Exception(__('domains/identity/messages.exceptions.user_cannot_be_edited')); |
||||
|
} |
||||
|
|
||||
|
$user->update(['status' => $status]); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,30 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Identity\Actions\IdentityMaintenance; |
||||
|
|
||||
|
use App\Domains\Identity\Models\User; |
||||
|
use App\Domains\System\Actions\Files\ReplaceSingleFile; |
||||
|
use App\Domains\System\DTOs\FileDTO; |
||||
|
use Illuminate\Http\UploadedFile; |
||||
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; |
||||
|
|
||||
|
class UpdateUserAvatar |
||||
|
{ |
||||
|
public function __construct(protected ReplaceSingleFile $replaceSingleFile) {} |
||||
|
|
||||
|
public function execute(User $user, UploadedFile|TemporaryUploadedFile $file): void |
||||
|
{ |
||||
|
$this->replaceSingleFile->execute( |
||||
|
newFile: $file, |
||||
|
dto: new FileDTO( |
||||
|
modelType: $user->getMorphClass(), |
||||
|
modelId: $user->id, |
||||
|
relationName: 'avatar', |
||||
|
disk: 'local', |
||||
|
directory: 'avatars', |
||||
|
options: [], |
||||
|
uploaderId: $user->id, |
||||
|
) |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,33 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Identity\Actions\IdentityMaintenance; |
||||
|
|
||||
|
use App\Domains\Identity\Actions\AccessControl\UpdateUserRole; |
||||
|
use App\Domains\Identity\DTOs\IdentityMaintenance\UpdateUserIdentityDTO; |
||||
|
use App\Domains\Identity\Models\User; |
||||
|
use Throwable; |
||||
|
|
||||
|
class UpdateUserIdentity |
||||
|
{ |
||||
|
public function __construct(protected UpdateUserRole $updateUserRole) {} |
||||
|
|
||||
|
/** |
||||
|
* @throws Throwable |
||||
|
*/ |
||||
|
public function execute(User $user, UpdateUserIdentityDTO $dto): User |
||||
|
{ |
||||
|
$user->fill([ |
||||
|
'name' => $dto->name, |
||||
|
'email' => $dto->email, |
||||
|
]); |
||||
|
|
||||
|
if ($user->isDirty('email')) { |
||||
|
$user->email_verified_at = null; |
||||
|
$user->sendEmailVerificationNotification(); |
||||
|
} |
||||
|
|
||||
|
$user->save(); |
||||
|
|
||||
|
return $user; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Identity\Actions\IdentityMaintenance; |
||||
|
|
||||
|
use App\Domains\Identity\Enums\UserSettingKey; |
||||
|
use App\Domains\Identity\Models\User; |
||||
|
|
||||
|
class UpdateUserSettings |
||||
|
{ |
||||
|
public function execute(User $user, array $newSettings): void |
||||
|
{ |
||||
|
$currentSettings = $user->settings ?? []; |
||||
|
|
||||
|
foreach (UserSettingKey::cases() as $key) { |
||||
|
if (array_key_exists($key->value, $newSettings)) { |
||||
|
$currentSettings[$key->value] = $newSettings[$key->value]; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$user->update(['settings' => $currentSettings]); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,36 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Identity\Actions\Onboarding; |
||||
|
|
||||
|
use App\Domains\Identity\Actions\AccessControl\UpdateUserRole; |
||||
|
use App\Domains\Identity\DTOs\Onboarding\ProvisionUserDTO; |
||||
|
use App\Domains\Identity\Events\Onboarding\UserWasProvisioned; |
||||
|
use App\Domains\Identity\Models\User; |
||||
|
use Illuminate\Support\Facades\DB; |
||||
|
use Throwable; |
||||
|
|
||||
|
class ProvisionNewUser |
||||
|
{ |
||||
|
public function __construct(protected UpdateUserRole $updateUserRole) {} |
||||
|
|
||||
|
/** |
||||
|
* @throws Throwable |
||||
|
*/ |
||||
|
public function execute(ProvisionUserDTO $dto): User |
||||
|
{ |
||||
|
$user = DB::transaction(function () use ($dto) { |
||||
|
$user = User::create([ |
||||
|
'name' => $dto->name, |
||||
|
'email' => $dto->email, |
||||
|
'password' => $dto->password, |
||||
|
]); |
||||
|
$this->updateUserRole->execute($user, [$dto->role]); |
||||
|
|
||||
|
return $user; |
||||
|
}); |
||||
|
|
||||
|
UserWasProvisioned::dispatch($user); |
||||
|
|
||||
|
return $user; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,50 @@ |
|||||
|
<?php |
||||
|
|
||||
|
namespace App\Domains\Identity\Actions\Onboarding; |
||||
|
|
||||
|
use App\Domains\Identity\Actions\AccessControl\UpdateUserRole; |
||||
|
use App\Domains\Identity\DTOs\Onboarding\RegisterSelfServiceUserDTO; |
||||
|
use App\Domains\Identity\Enums\RoleType; |
||||
|
use App\Domains\Identity\Events\Onboarding\UserWasRegistered; |
||||
|
use App\Domains\Identity\Models\User; |
||||
|
use Illuminate\Auth\Events\Registered; |
||||
|
use Illuminate\Support\Facades\DB; |
||||
|
|
||||
|
class RegisterSelfServiceUser |
||||
|
{ |
||||
|
public function __construct(protected UpdateUserRole $updateUserRole) {} |
||||
|
|
||||
|
/** |
||||
|
* Create a new user via self-service registration and dispatch all events. |
||||
|
* |
||||
|
* Fires two events: |
||||
|
* - Illuminate\Auth\Events\Registered — triggers Laravel's built-in |
||||
|
* email verification notification listener automatically. |
||||
|
* - App\Domains\Identity\Events\Onboarding\UserWasRegistered — the domain |
||||
|
* event for any application-specific listeners (welcome emails, etc.). |
||||
|
* |
||||
|
* Note: Auth::login() is intentionally absent. Logging the user into the |
||||
|
* session is a framework/HTTP concern that belongs to the Auth Gateway. |
||||
|
*/ |
||||
|
public function execute(RegisterSelfServiceUserDTO $dto): User |
||||
|
{ |
||||
|
$user = DB::transaction(function () use ($dto) { |
||||
|
$user = User::create([ |
||||
|
'name' => $dto->name, |
||||
|
'email' => $dto->email, |
||||
|
'password' => $dto->password, |
||||
|
]); |
||||
|
$this->updateUserRole->execute($user, [RoleType::USER]); |
||||
|
|
||||
|
return $user; |
||||
|
}); |
||||
|
|
||||
|
// Framework event: triggers SendEmailVerificationNotification listener. |
||||
|
event(new Registered($user)); |
||||
|
|
||||
|
// Domain event: for application-level listeners. |
||||
|
UserWasRegistered::dispatch($user, $dto); |
||||
|
|
||||
|
return $user; |
||||
|
} |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue