Add first edition of AndyTranscribe.
This commit is contained in:
@@ -0,0 +1,483 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
The `#[WithoutBroadcasting]` attribute stops the given stream event types from broadcasting (e.g. data-heavy `ToolResult` payloads that exceed the WebSocket frame limit). The events are still streamed and persisted; they just never hit the channel:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Ai\Attributes\WithoutBroadcasting;
|
||||||
|
use Laravel\Ai\Streaming\Events\{ToolCall, ToolResult};
|
||||||
|
|
||||||
|
#[WithoutBroadcasting(ToolResult::class, ToolCall::class)]
|
||||||
|
class SearchAgent implements Agent, HasTools
|
||||||
|
{
|
||||||
|
use Promptable;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
## OpenAI-Compatible Provider
|
||||||
|
|
||||||
|
Point the SDK at any OpenAI-compatible endpoint (LM Studio, vLLM, Together, etc.) with the config-driven `openai-compatible` driver. Define named instances in `config/ai.php`, no code required:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'my-llm' => [
|
||||||
|
'driver' => 'openai-compatible',
|
||||||
|
'url' => env('MY_LLM_URL'), // required
|
||||||
|
'key' => env('MY_LLM_API_KEY'), // optional Bearer token
|
||||||
|
'models' => [
|
||||||
|
'text' => ['default' => 'some-chat-model'],
|
||||||
|
'embeddings' => [
|
||||||
|
'default' => 'some-embedding-model',
|
||||||
|
'dimensions' => 1024, // optional; omit to use native dimensions
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference it by config key (or `Lab::OpenAiCompatible`). A model is required via the corresponding `models` configuration or per-call `model:`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
agent()->prompt('Hello', provider: 'my-llm', model: 'some-model');
|
||||||
|
|
||||||
|
Embeddings::for(['Hello'])->generate(
|
||||||
|
provider: 'my-llm',
|
||||||
|
model: 'some-embedding-model',
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
It uses OpenAI-standard shapes and supports text, streaming, tools, structured output, image attachments, and text embeddings. Embedding dimensions are optional; omit them to use the model's native dimensions. For extra request-body fields, implement `HasProviderOptions` — the returned array is merged into the body.
|
||||||
|
|
||||||
|
## 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, OpenRouter, OpenAI-compatible |
|
||||||
|
| Images | OpenAI, Gemini, xAI |
|
||||||
|
| TTS | OpenAI, ElevenLabs |
|
||||||
|
| STT | OpenAI, ElevenLabs, Mistral |
|
||||||
|
| Embeddings | OpenAI, OpenAI-compatible, 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;
|
||||||
|
Lab::OpenAiCompatible; // configurable OpenAI-compatible endpoint
|
||||||
|
// ...
|
||||||
|
```
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
name: infer-conventions
|
||||||
|
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Infer Conventions
|
||||||
|
|
||||||
|
Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it.
|
||||||
|
|
||||||
|
## Ground Rules (read before you start)
|
||||||
|
|
||||||
|
- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer.
|
||||||
|
- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record.
|
||||||
|
- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule.
|
||||||
|
- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering.
|
||||||
|
- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped.
|
||||||
|
- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar.
|
||||||
|
- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
Each step ends on a checkable completion criterion. Do not advance until it holds.
|
||||||
|
|
||||||
|
Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output.
|
||||||
|
|
||||||
|
### Step 0: Orient
|
||||||
|
|
||||||
|
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
|
||||||
|
|
||||||
|
This app has no Livewire/Inertia/Flux packages installed. Treat the frontend group as likely API-only: confirm from `resources/views` before spending time there, and skip the Livewire/Inertia/Flux dimensions.
|
||||||
|
|
||||||
|
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
|
||||||
|
|
||||||
|
### Step 1: Predefined sweep
|
||||||
|
|
||||||
|
Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict:
|
||||||
|
|
||||||
|
- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files.
|
||||||
|
- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled.
|
||||||
|
- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention.
|
||||||
|
- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most).
|
||||||
|
- Tooling-owned or Already-recorded. Skip per the ground rules.
|
||||||
|
|
||||||
|
Done when: every applicable dimension carries exactly one of those verdicts.
|
||||||
|
|
||||||
|
### Step 2: Open-ended pass
|
||||||
|
|
||||||
|
First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude.
|
||||||
|
|
||||||
|
Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal.
|
||||||
|
|
||||||
|
Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none).
|
||||||
|
|
||||||
|
### Step 3: Confirm
|
||||||
|
|
||||||
|
Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style.
|
||||||
|
|
||||||
|
Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo.
|
||||||
|
|
||||||
|
Done when: every candidate is approved, rejected, or (conflicts) decided.
|
||||||
|
|
||||||
|
### Step 4: Record
|
||||||
|
|
||||||
|
Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand.
|
||||||
|
|
||||||
|
Record this:
|
||||||
|
|
||||||
|
> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models.
|
||||||
|
|
||||||
|
Not this:
|
||||||
|
|
||||||
|
> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models.
|
||||||
|
|
||||||
|
Done when: every approved item has a successful tool response, and any failure is reported with its rule text.
|
||||||
|
|
||||||
|
### Step 5: Summarize
|
||||||
|
|
||||||
|
List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions.
|
||||||
|
|
||||||
|
## Glob mapping
|
||||||
|
|
||||||
|
Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one.
|
||||||
|
- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer.
|
||||||
|
- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses.
|
||||||
|
- Tests: `tests/**`.
|
||||||
|
- Migrations and database: `database/migrations/**`.
|
||||||
|
- Truly app-wide (rare, e.g. auth retrieval): `app/**`.
|
||||||
|
|
||||||
|
`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4.
|
||||||
|
- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing.
|
||||||
|
- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything.
|
||||||
|
- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface.
|
||||||
|
- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths.
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Detection Checklist
|
||||||
|
|
||||||
|
Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`).
|
||||||
|
|
||||||
|
Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Validation & HTTP input
|
||||||
|
|
||||||
|
1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
|
||||||
|
- Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`.
|
||||||
|
2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
|
||||||
|
- Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
|
||||||
|
3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
|
||||||
|
- Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
|
||||||
|
4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
|
||||||
|
- Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
|
||||||
|
|
||||||
|
## B. Controllers & routing
|
||||||
|
|
||||||
|
5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method.
|
||||||
|
- Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes.
|
||||||
|
6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs.
|
||||||
|
- Hint: read a few controller methods; `ls app/Actions app/Services`.
|
||||||
|
7. Route handler style: closures in `routes/*.php` vs controller classes.
|
||||||
|
- Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
|
||||||
|
8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
|
||||||
|
- Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
|
||||||
|
9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
|
||||||
|
- Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
|
||||||
|
10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
|
||||||
|
- Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files.
|
||||||
|
|
||||||
|
## C. Authorization
|
||||||
|
|
||||||
|
11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
|
||||||
|
- Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
|
||||||
|
12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
|
||||||
|
- Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
|
||||||
|
|
||||||
|
## D. Eloquent & models
|
||||||
|
|
||||||
|
13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list.
|
||||||
|
- Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`.
|
||||||
|
14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain.
|
||||||
|
- Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`.
|
||||||
|
15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`.
|
||||||
|
- Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`.
|
||||||
|
16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings.
|
||||||
|
- Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models.
|
||||||
|
17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`).
|
||||||
|
- Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built.
|
||||||
|
18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes.
|
||||||
|
- Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`.
|
||||||
|
19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
|
||||||
|
- Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
|
||||||
|
20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
|
||||||
|
- Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
|
||||||
|
|
||||||
|
## E. Architecture & organization
|
||||||
|
|
||||||
|
21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked.
|
||||||
|
- Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find.
|
||||||
|
22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere.
|
||||||
|
- Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`.
|
||||||
|
23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location.
|
||||||
|
- Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps.
|
||||||
|
24. Decoupling: events + listeners vs direct service calls.
|
||||||
|
- Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`.
|
||||||
|
25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`).
|
||||||
|
- Hint: ratio of `config(` vs `Config::` (etc.) across `app/`.
|
||||||
|
26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules).
|
||||||
|
- Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders.
|
||||||
|
27. Enums: backed vs pure; case naming; where they live.
|
||||||
|
- Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`.
|
||||||
|
|
||||||
|
## F. Frontend & views
|
||||||
|
|
||||||
|
No Livewire/Inertia/Flux package is installed. This app may be API-only. Confirm from `resources/views` before sweeping, and treat the Livewire/Flux dimensions as not applicable.
|
||||||
|
|
||||||
|
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
|
||||||
|
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
|
||||||
|
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
|
||||||
|
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
|
||||||
|
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
|
||||||
|
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
|
||||||
|
|
||||||
|
## G. Database & migrations
|
||||||
|
|
||||||
|
33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
|
||||||
|
- Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
|
||||||
|
34. `down()` methods: real reverse logic vs omitted / one-way migrations.
|
||||||
|
- Hint: grep `function down` vs the migration count.
|
||||||
|
35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
|
||||||
|
- Hint: grep `->enum(` in migrations vs string columns cast to enums.
|
||||||
|
36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
|
||||||
|
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
|
||||||
|
37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
|
||||||
|
- Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`.
|
||||||
|
|
||||||
|
## H. Testing
|
||||||
|
|
||||||
|
38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes.
|
||||||
|
- Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`.
|
||||||
|
39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`.
|
||||||
|
- Hint: grep those trait names in `tests/`.
|
||||||
|
40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
|
||||||
|
- Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
|
||||||
|
41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
|
||||||
|
- Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
|
||||||
|
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
|
||||||
|
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
|
||||||
|
|
||||||
|
## I. Responses & API resources
|
||||||
|
|
||||||
|
43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
|
||||||
|
- Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers.
|
||||||
|
44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
|
||||||
|
- Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
|
||||||
|
45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
|
||||||
|
- Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them.
|
||||||
|
46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`.
|
||||||
|
- Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views.
|
||||||
|
|
||||||
|
## J. Strings, collections & dates
|
||||||
|
|
||||||
|
47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
|
||||||
|
- Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
|
||||||
|
48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
|
||||||
|
- Hint: grep `Str::of(` vs `Str::` vs native string funcs.
|
||||||
|
49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
|
||||||
|
- Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
name: laravel-best-practices
|
||||||
|
description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Laravel Best Practices
|
||||||
|
|
||||||
|
Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`.
|
||||||
|
|
||||||
|
## Consistency First
|
||||||
|
|
||||||
|
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
|
||||||
|
|
||||||
|
Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
|
||||||
|
|
||||||
|
## How to Apply
|
||||||
|
|
||||||
|
1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out.
|
||||||
|
2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files.
|
||||||
|
3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job.
|
||||||
|
4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable.
|
||||||
|
5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them.
|
||||||
|
6. Re-read the diff against every mapped rule before finishing.
|
||||||
|
|
||||||
|
## Rule Index
|
||||||
|
|
||||||
|
Cross-cutting changes often need more than one rule file.
|
||||||
|
|
||||||
|
| Concern | Read |
|
||||||
|
| --- | --- |
|
||||||
|
| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) |
|
||||||
|
| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) |
|
||||||
|
| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) |
|
||||||
|
| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) |
|
||||||
|
| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) |
|
||||||
|
| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) |
|
||||||
|
| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) |
|
||||||
|
| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) |
|
||||||
|
| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) |
|
||||||
|
| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) |
|
||||||
|
| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) |
|
||||||
|
| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) |
|
||||||
|
| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) |
|
||||||
|
| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) |
|
||||||
|
| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) |
|
||||||
|
| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) |
|
||||||
|
| Environment values and application configuration | [`rules/config.md`](rules/config.md) |
|
||||||
|
| Pest/PHPUnit patterns, factories, fakes | [`rules/testing.md`](rules/testing.md) |
|
||||||
|
| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) |
|
||||||
|
| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) |
|
||||||
|
|
||||||
|
## Decision Rules
|
||||||
|
|
||||||
|
- Prefer framework features and existing application abstractions over new helpers or dependencies.
|
||||||
|
- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable.
|
||||||
|
- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Advanced Query Patterns
|
||||||
|
|
||||||
|
## Use `addSelect()` Subqueries for Single Values from Has-Many
|
||||||
|
|
||||||
|
Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function scopeWithLastLoginAt($query): void
|
||||||
|
{
|
||||||
|
$query->addSelect([
|
||||||
|
'last_login_at' => Login::select('created_at')
|
||||||
|
->whereColumn('user_id', 'users.id')
|
||||||
|
->latest()
|
||||||
|
->take(1),
|
||||||
|
])->withCasts(['last_login_at' => 'datetime']);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Create Dynamic Relationships via Subquery FK
|
||||||
|
|
||||||
|
Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function lastLogin(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Login::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeWithLastLogin($query): void
|
||||||
|
{
|
||||||
|
$query->addSelect([
|
||||||
|
'last_login_id' => Login::select('id')
|
||||||
|
->whereColumn('user_id', 'users.id')
|
||||||
|
->latest()
|
||||||
|
->take(1),
|
||||||
|
])->with('lastLogin');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Conditional Aggregates Instead of Multiple Count Queries
|
||||||
|
|
||||||
|
Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$statuses = Feature::toBase()
|
||||||
|
->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
|
||||||
|
->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
|
||||||
|
->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
|
||||||
|
->first();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `setRelation()` to Prevent Circular N+1
|
||||||
|
|
||||||
|
When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$feature->load('comments.user');
|
||||||
|
$feature->comments->each->setRelation('feature', $feature);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prefer `whereIn` + Subquery Over `whereHas`
|
||||||
|
|
||||||
|
`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory.
|
||||||
|
|
||||||
|
Incorrect (correlated EXISTS re-executes per row):
|
||||||
|
|
||||||
|
```php
|
||||||
|
$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (index-friendly subquery, no PHP memory overhead):
|
||||||
|
|
||||||
|
```php
|
||||||
|
$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sometimes Two Simple Queries Beat One Complex Query
|
||||||
|
|
||||||
|
Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index.
|
||||||
|
|
||||||
|
## Use Compound Indexes Matching `orderBy` Column Order
|
||||||
|
|
||||||
|
When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Migration
|
||||||
|
$table->index(['last_name', 'first_name']);
|
||||||
|
|
||||||
|
// Query — column order must match the index
|
||||||
|
User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Correlated Subqueries for Has-Many Ordering
|
||||||
|
|
||||||
|
When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function scopeOrderByLastLogin($query): void
|
||||||
|
{
|
||||||
|
$query->orderByDesc(Login::select('created_at')
|
||||||
|
->whereColumn('user_id', 'users.id')
|
||||||
|
->latest()
|
||||||
|
->take(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# Architecture Best Practices
|
||||||
|
|
||||||
|
## Single-Purpose Action Classes
|
||||||
|
|
||||||
|
Extract discrete business operations into invokable Action classes.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class CreateOrderAction
|
||||||
|
{
|
||||||
|
public function __construct(private InventoryService $inventory) {}
|
||||||
|
|
||||||
|
public function handle(array $data): Order
|
||||||
|
{
|
||||||
|
$order = Order::create($data);
|
||||||
|
$this->inventory->reserve($order);
|
||||||
|
|
||||||
|
return $order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Dependency Injection
|
||||||
|
|
||||||
|
Always use constructor injection. Avoid `app()` or `resolve()` inside classes.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
class OrderController extends Controller
|
||||||
|
{
|
||||||
|
public function store(StoreOrderRequest $request)
|
||||||
|
{
|
||||||
|
$service = app(OrderService::class);
|
||||||
|
|
||||||
|
return $service->create($request->validated());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
class OrderController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private OrderService $service) {}
|
||||||
|
|
||||||
|
public function store(StoreOrderRequest $request)
|
||||||
|
{
|
||||||
|
return $this->service->create($request->validated());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code to Interfaces
|
||||||
|
|
||||||
|
Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
|
||||||
|
|
||||||
|
Incorrect (concrete dependency):
|
||||||
|
```php
|
||||||
|
class OrderService
|
||||||
|
{
|
||||||
|
public function __construct(private StripeGateway $gateway) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (interface dependency):
|
||||||
|
```php
|
||||||
|
interface PaymentGateway
|
||||||
|
{
|
||||||
|
public function charge(int $amount, string $customerId): PaymentResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OrderService
|
||||||
|
{
|
||||||
|
public function __construct(private PaymentGateway $gateway) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Bind in a service provider:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$this->app->bind(PaymentGateway::class, StripeGateway::class);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Default Sort by Descending
|
||||||
|
|
||||||
|
When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$posts = Post::paginate();
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
$posts = Post::latest()->paginate();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Atomic Locks for Race Conditions
|
||||||
|
|
||||||
|
Prevent race conditions with `Cache::lock()` or `lockForUpdate()`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
|
||||||
|
$order->process();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Or at query level
|
||||||
|
$product = Product::where('id', $id)->lockForUpdate()->first();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `mb_*` String Functions
|
||||||
|
|
||||||
|
When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
strlen('José'); // 5 (bytes, not characters)
|
||||||
|
strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
mb_strlen('José'); // 4 (characters)
|
||||||
|
mb_strtolower('MÜNCHEN'); // 'münchen'
|
||||||
|
|
||||||
|
// Prefer Laravel's Str helpers when available
|
||||||
|
Str::length('José'); // 4
|
||||||
|
Str::lower('MÜNCHEN'); // 'münchen'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `defer()` for Post-Response Work
|
||||||
|
|
||||||
|
For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead.
|
||||||
|
|
||||||
|
Incorrect (job overhead for trivial work):
|
||||||
|
```php
|
||||||
|
dispatch(new LogPageView($page));
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (runs after response, same process):
|
||||||
|
```php
|
||||||
|
defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()]));
|
||||||
|
```
|
||||||
|
|
||||||
|
Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work.
|
||||||
|
|
||||||
|
## Use `Context` for Request-Scoped Data
|
||||||
|
|
||||||
|
The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In middleware
|
||||||
|
Context::add('tenant_id', $request->header('X-Tenant-ID'));
|
||||||
|
|
||||||
|
// Anywhere later — controllers, jobs, log context
|
||||||
|
$tenantId = Context::get('tenant_id');
|
||||||
|
```
|
||||||
|
|
||||||
|
Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`.
|
||||||
|
|
||||||
|
## Use `Concurrency::run()` for Parallel Execution
|
||||||
|
|
||||||
|
Run independent operations in parallel using child processes — no async libraries needed.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Illuminate\Support\Facades\Concurrency;
|
||||||
|
|
||||||
|
[$users, $orders] = Concurrency::run([
|
||||||
|
fn () => User::count(),
|
||||||
|
fn () => Order::where('status', 'pending')->count(),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially.
|
||||||
|
|
||||||
|
## Convention Over Configuration
|
||||||
|
|
||||||
|
Follow Laravel conventions. Don't override defaults unnecessarily.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
class Customer extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'Customer';
|
||||||
|
protected $primaryKey = 'customer_id';
|
||||||
|
|
||||||
|
public function roles(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
class Customer extends Model
|
||||||
|
{
|
||||||
|
public function roles(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Role::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Blade & Views Best Practices
|
||||||
|
|
||||||
|
## Use `$attributes->merge()` in Component Templates
|
||||||
|
|
||||||
|
Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly.
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<div {{ $attributes->merge(['class' => 'alert alert-'.$type]) }}>
|
||||||
|
{{ $message }}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `@pushOnce` for Per-Component Scripts
|
||||||
|
|
||||||
|
If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once.
|
||||||
|
|
||||||
|
## Prefer Blade Components Over `@include`
|
||||||
|
|
||||||
|
`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots.
|
||||||
|
|
||||||
|
## Use View Composers for Shared View Data
|
||||||
|
|
||||||
|
If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it.
|
||||||
|
|
||||||
|
## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
|
||||||
|
|
||||||
|
A single view can return either the full page or just a fragment, keeping routing clean.
|
||||||
|
|
||||||
|
```php
|
||||||
|
return view('dashboard', compact('users'))
|
||||||
|
->fragmentIf($request->hasHeader('HX-Request'), 'user-list');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `@aware` for Deeply Nested Component Props
|
||||||
|
|
||||||
|
Avoids re-passing parent props through every level of nested components.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Caching Best Practices
|
||||||
|
|
||||||
|
## Use `Cache::remember()` Instead of Manual Get/Put
|
||||||
|
|
||||||
|
Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$val = Cache::get('stats');
|
||||||
|
if (! $val) {
|
||||||
|
$val = $this->computeStats();
|
||||||
|
Cache::put('stats', $val, 60);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
$val = Cache::remember('stats', 60, fn () => $this->computeStats());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `Cache::flexible()` for Stale-While-Revalidate
|
||||||
|
|
||||||
|
On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background.
|
||||||
|
|
||||||
|
Incorrect: `Cache::remember('users', 300, fn () => User::all());`
|
||||||
|
|
||||||
|
Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function.
|
||||||
|
|
||||||
|
## Use `Cache::memo()` to Avoid Redundant Hits Within a Request
|
||||||
|
|
||||||
|
If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory.
|
||||||
|
|
||||||
|
`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5.
|
||||||
|
|
||||||
|
## Use Cache Tags to Invalidate Related Groups
|
||||||
|
|
||||||
|
Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Cache::tags(['user-1'])->flush();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `Cache::add()` for Atomic Conditional Writes
|
||||||
|
|
||||||
|
`add()` only writes if the key does not exist — atomic, no race condition between checking and writing.
|
||||||
|
|
||||||
|
Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }`
|
||||||
|
|
||||||
|
Correct: `Cache::add('lock', true, 10);`
|
||||||
|
|
||||||
|
## Use `once()` for Per-Request Memoization
|
||||||
|
|
||||||
|
`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function roles(): Collection
|
||||||
|
{
|
||||||
|
return once(fn () => $this->loadRoles());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching.
|
||||||
|
|
||||||
|
## Configure Failover Cache Stores in Production
|
||||||
|
|
||||||
|
If Redis goes down, the app falls back to a secondary store automatically.
|
||||||
|
|
||||||
|
```php
|
||||||
|
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
|
||||||
|
```
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Collection Best Practices
|
||||||
|
|
||||||
|
## Use Higher-Order Messages for Simple Operations
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$users->each(function (User $user) {
|
||||||
|
$user->markAsVip();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct: `$users->each->markAsVip();`
|
||||||
|
|
||||||
|
Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc.
|
||||||
|
|
||||||
|
## Choose `cursor()` vs. `lazy()` Correctly
|
||||||
|
|
||||||
|
- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk).
|
||||||
|
- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading.
|
||||||
|
|
||||||
|
Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored.
|
||||||
|
|
||||||
|
Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work.
|
||||||
|
|
||||||
|
## Use `lazyById()` When Updating Records While Iterating
|
||||||
|
|
||||||
|
`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation.
|
||||||
|
|
||||||
|
## Use `toQuery()` for Bulk Operations on Collections
|
||||||
|
|
||||||
|
Avoids manual `whereIn` construction.
|
||||||
|
|
||||||
|
Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);`
|
||||||
|
|
||||||
|
Correct: `$users->toQuery()->update([...]);`
|
||||||
|
|
||||||
|
## Use `#[CollectedBy]` for Custom Collection Classes
|
||||||
|
|
||||||
|
More declarative than overriding `newCollection()`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[CollectedBy(UserCollection::class)]
|
||||||
|
class User extends Model {}
|
||||||
|
```
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Configuration Best Practices
|
||||||
|
|
||||||
|
## `env()` Only in Config Files
|
||||||
|
|
||||||
|
Direct `env()` calls may return `null` when config is cached.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$key = env('API_KEY');
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
// config/services.php
|
||||||
|
'key' => env('API_KEY'),
|
||||||
|
|
||||||
|
// Application code
|
||||||
|
$key = config('services.key');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Encrypted Env or External Secrets
|
||||||
|
|
||||||
|
Never store production secrets in plain `.env` files in version control.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# .env committed to repo or shared in Slack
|
||||||
|
|
||||||
|
STRIPE_SECRET=sk_live_abc123
|
||||||
|
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```bash
|
||||||
|
php artisan env:encrypt --env=production --readable
|
||||||
|
php artisan env:decrypt --env=production
|
||||||
|
```
|
||||||
|
|
||||||
|
For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime.
|
||||||
|
|
||||||
|
## Use `App::environment()` for Environment Checks
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
if (env('APP_ENV') === 'production') {
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
if (app()->isProduction()) {
|
||||||
|
// or
|
||||||
|
if (App::environment('production')) {
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Constants and Language Files
|
||||||
|
|
||||||
|
Use class constants instead of hardcoded magic strings for model states, types, and statuses.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Incorrect
|
||||||
|
return $this->type === 'normal';
|
||||||
|
|
||||||
|
// Correct
|
||||||
|
return $this->type === self::TYPE_NORMAL;
|
||||||
|
```
|
||||||
|
|
||||||
|
If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Only when lang files already exist in the project
|
||||||
|
return back()->with('message', __('app.article_added'));
|
||||||
|
```
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Database Performance Best Practices
|
||||||
|
|
||||||
|
## Always Eager Load Relationships
|
||||||
|
|
||||||
|
Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront.
|
||||||
|
|
||||||
|
Incorrect (N+1 — executes 1 + N queries):
|
||||||
|
```php
|
||||||
|
$posts = Post::all();
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
echo $post->author->name;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (2 queries total):
|
||||||
|
```php
|
||||||
|
$posts = Post::with('author')->get();
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
echo $post->author->name;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Constrain eager loads to select only needed columns (always include the foreign key):
|
||||||
|
|
||||||
|
```php
|
||||||
|
$users = User::with(['posts' => function ($query) {
|
||||||
|
$query->select('id', 'user_id', 'title')
|
||||||
|
->where('published', true)
|
||||||
|
->latest()
|
||||||
|
->limit(10);
|
||||||
|
}])->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prevent Lazy Loading in Development
|
||||||
|
|
||||||
|
Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
Model::preventLazyLoading(! app()->isProduction());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded.
|
||||||
|
|
||||||
|
## Select Only Needed Columns
|
||||||
|
|
||||||
|
Avoid `SELECT *` — especially when tables have large text or JSON columns.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$posts = Post::with('author')->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
$posts = Post::select('id', 'title', 'user_id', 'created_at')
|
||||||
|
->with(['author:id,name,avatar'])
|
||||||
|
->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match.
|
||||||
|
|
||||||
|
## Chunk Large Datasets
|
||||||
|
|
||||||
|
Never load thousands of records at once. Use chunking for batch processing.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$users = User::all();
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$user->notify(new WeeklyDigest);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
User::where('subscribed', true)->chunk(200, function ($users) {
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$user->notify(new WeeklyDigest);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change:
|
||||||
|
|
||||||
|
```php
|
||||||
|
User::where('active', false)->chunkById(200, function ($users) {
|
||||||
|
$users->each->delete();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Add Database Indexes
|
||||||
|
|
||||||
|
Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
Schema::create('orders', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained();
|
||||||
|
$table->string('status');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
Schema::create('orders', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->index()->constrained();
|
||||||
|
$table->string('status')->index();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->index(['status', 'created_at']);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`).
|
||||||
|
|
||||||
|
## Use `withCount()` for Counting Relations
|
||||||
|
|
||||||
|
Never load entire collections just to count them.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$posts = Post::all();
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
echo $post->comments->count();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
$posts = Post::withCount('comments')->get();
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
echo $post->comments_count;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Conditional counting:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$posts = Post::withCount([
|
||||||
|
'comments',
|
||||||
|
'comments as approved_comments_count' => function ($query) {
|
||||||
|
$query->where('approved', true);
|
||||||
|
},
|
||||||
|
])->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `cursor()` for Memory-Efficient Iteration
|
||||||
|
|
||||||
|
For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$users = User::where('active', true)->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
foreach (User::where('active', true)->cursor() as $user) {
|
||||||
|
ProcessUser::dispatch($user->id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records.
|
||||||
|
|
||||||
|
## No Queries in Blade Templates
|
||||||
|
|
||||||
|
Never execute queries in Blade templates. Pass data from controllers.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```blade
|
||||||
|
@foreach (User::all() as $user)
|
||||||
|
{{ $user->profile->name }}
|
||||||
|
@endforeach
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
// Controller
|
||||||
|
$users = User::with('profile')->get();
|
||||||
|
return view('users.index', compact('users'));
|
||||||
|
```
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@foreach ($users as $user)
|
||||||
|
{{ $user->profile->name }}
|
||||||
|
@endforeach
|
||||||
|
```
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Eloquent Best Practices
|
||||||
|
|
||||||
|
## Use Correct Relationship Types
|
||||||
|
|
||||||
|
Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function comments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Comment::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function author(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Local Scopes for Reusable Queries
|
||||||
|
|
||||||
|
Extract reusable query constraints into local scopes to avoid duplication.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$active = User::where('verified', true)->whereNotNull('activated_at')->get();
|
||||||
|
$articles = Article::whereHas('user', function ($q) {
|
||||||
|
$q->where('verified', true)->whereNotNull('activated_at');
|
||||||
|
})->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
#[Scope]
|
||||||
|
protected function active(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('verified', true)->whereNotNull('activated_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
$active = User::active()->get();
|
||||||
|
$articles = Article::whereHas('user', fn ($q) => $q->active())->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Apply Global Scopes Sparingly
|
||||||
|
|
||||||
|
Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy.
|
||||||
|
|
||||||
|
Incorrect (global scope for a conditional filter):
|
||||||
|
```php
|
||||||
|
class PublishedScope implements Scope
|
||||||
|
{
|
||||||
|
public function apply(Builder $builder, Model $model): void
|
||||||
|
{
|
||||||
|
$builder->where('published', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Now admin panels, reports, and background jobs all silently skip drafts
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (local scope you opt into):
|
||||||
|
```php
|
||||||
|
#[Scope]
|
||||||
|
protected function published(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('published', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Post::published()->paginate(); // Explicit
|
||||||
|
Post::paginate(); // Admin sees all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Define Attribute Casts
|
||||||
|
|
||||||
|
Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion.
|
||||||
|
|
||||||
|
```php
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'metadata' => 'array',
|
||||||
|
'total' => 'decimal:2',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cast Date Columns Properly
|
||||||
|
|
||||||
|
Always cast date columns. Use Carbon instances in templates instead of formatting strings manually.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```blade
|
||||||
|
{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ordered_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{ $order->ordered_at->toDateString() }}
|
||||||
|
{{ $order->ordered_at->format('m-d') }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `whereBelongsTo()` for Relationship Queries
|
||||||
|
|
||||||
|
Cleaner than manually specifying foreign keys.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
Post::where('user_id', $user->id)->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
Post::whereBelongsTo($user)->get();
|
||||||
|
Post::whereBelongsTo($user, 'author')->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Avoid Hardcoded Table Names in Queries
|
||||||
|
|
||||||
|
Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string).
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
DB::table('users')->where('active', true)->get();
|
||||||
|
|
||||||
|
$query->join('companies', 'companies.id', '=', 'users.company_id');
|
||||||
|
|
||||||
|
DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct — reference the model's table:
|
||||||
|
```php
|
||||||
|
DB::table((new User)->getTable())->where('active', true)->get();
|
||||||
|
|
||||||
|
// Even better — use Eloquent or the query builder instead of raw SQL
|
||||||
|
User::where('active', true)->get();
|
||||||
|
Order::where('status', 'pending')->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
|
||||||
|
|
||||||
|
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Error Handling Best Practices
|
||||||
|
|
||||||
|
## Exception Reporting and Rendering
|
||||||
|
|
||||||
|
There are two valid approaches — choose one and apply it consistently across the project.
|
||||||
|
|
||||||
|
**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class InvalidOrderException extends Exception
|
||||||
|
{
|
||||||
|
public function report(): void { /* custom reporting */ }
|
||||||
|
|
||||||
|
public function render(Request $request): Response
|
||||||
|
{
|
||||||
|
return response()->view('errors.invalid-order', status: 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture:
|
||||||
|
|
||||||
|
```php
|
||||||
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
|
$exceptions->report(function (InvalidOrderException $e) { /* ... */ });
|
||||||
|
$exceptions->render(function (InvalidOrderException $e, Request $request) {
|
||||||
|
return response()->view('errors.invalid-order', status: 422);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the existing codebase and follow whichever pattern is already established.
|
||||||
|
|
||||||
|
## Use `ShouldntReport` for Exceptions That Should Never Log
|
||||||
|
|
||||||
|
More discoverable than listing classes in `dontReport()`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class PodcastProcessingException extends Exception implements ShouldntReport {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Throttle High-Volume Exceptions
|
||||||
|
|
||||||
|
A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type.
|
||||||
|
|
||||||
|
## Enable `dontReportDuplicates()`
|
||||||
|
|
||||||
|
Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks.
|
||||||
|
|
||||||
|
## Force JSON Error Rendering for API Routes
|
||||||
|
|
||||||
|
Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
|
||||||
|
return $request->is('api/*') || $request->expectsJson();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Add Context to Exception Classes
|
||||||
|
|
||||||
|
Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class InvalidOrderException extends Exception
|
||||||
|
{
|
||||||
|
public function context(): array
|
||||||
|
{
|
||||||
|
return ['order_id' => $this->orderId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Events & Notifications Best Practices
|
||||||
|
|
||||||
|
## Rely on Event Discovery
|
||||||
|
|
||||||
|
Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`.
|
||||||
|
|
||||||
|
## Run `event:cache` in Production Deploy
|
||||||
|
|
||||||
|
Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`.
|
||||||
|
|
||||||
|
## Use `ShouldDispatchAfterCommit` Inside Transactions
|
||||||
|
|
||||||
|
Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class OrderShipped implements ShouldDispatchAfterCommit {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Always Queue Notifications
|
||||||
|
|
||||||
|
Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class InvoicePaid extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `afterCommit()` on Notifications in Transactions
|
||||||
|
|
||||||
|
Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$user->notify((new InvoicePaid($invoice))->afterCommit());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Route Notification Channels to Dedicated Queues
|
||||||
|
|
||||||
|
Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues.
|
||||||
|
|
||||||
|
## Use On-Demand Notifications for Non-User Recipients
|
||||||
|
|
||||||
|
Avoid creating dummy models to send notifications to arbitrary addresses.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Notification::route('mail', 'admin@example.com')->notify(new SystemAlert());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implement `HasLocalePreference` on Notifiable Models
|
||||||
|
|
||||||
|
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# HTTP Client Best Practices
|
||||||
|
|
||||||
|
## Always Set Explicit Timeouts
|
||||||
|
|
||||||
|
The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$response = Http::get('https://api.example.com/users');
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
$response = Http::timeout(5)
|
||||||
|
->connectTimeout(3)
|
||||||
|
->get('https://api.example.com/users');
|
||||||
|
```
|
||||||
|
|
||||||
|
For service-specific clients, define timeouts in a macro:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Http::macro('github', function () {
|
||||||
|
return Http::baseUrl('https://api.github.com')
|
||||||
|
->timeout(10)
|
||||||
|
->connectTimeout(3)
|
||||||
|
->withToken(config('services.github.token'));
|
||||||
|
});
|
||||||
|
|
||||||
|
$response = Http::github()->get('/repos/laravel/framework');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Retry with Backoff for External APIs
|
||||||
|
|
||||||
|
External APIs have transient failures. Use `retry()` with increasing delays.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$response = Http::post('https://api.stripe.com/v1/charges', $data);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new PaymentFailedException('Charge failed');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
$response = Http::retry([100, 500, 1000])
|
||||||
|
->timeout(10)
|
||||||
|
->post('https://api.stripe.com/v1/charges', $data);
|
||||||
|
```
|
||||||
|
|
||||||
|
Only retry on specific errors:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
|
||||||
|
return $exception instanceof ConnectionException
|
||||||
|
|| ($exception instanceof RequestException && $exception->response->serverError());
|
||||||
|
})->post('https://api.example.com/data');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Handle Errors Explicitly
|
||||||
|
|
||||||
|
The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$response = Http::get('https://api.example.com/users/1');
|
||||||
|
$user = $response->json(); // Could be an error body
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
$response = Http::timeout(5)
|
||||||
|
->get('https://api.example.com/users/1')
|
||||||
|
->throw();
|
||||||
|
|
||||||
|
$user = $response->json();
|
||||||
|
```
|
||||||
|
|
||||||
|
For graceful degradation:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$response = Http::get('https://api.example.com/users/1');
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return $response->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->notFound()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->throw();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Request Pooling for Concurrent Requests
|
||||||
|
|
||||||
|
When making multiple independent API calls, use `Http::pool()` instead of sequential calls.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$users = Http::get('https://api.example.com/users')->json();
|
||||||
|
$posts = Http::get('https://api.example.com/posts')->json();
|
||||||
|
$comments = Http::get('https://api.example.com/comments')->json();
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
use Illuminate\Http\Client\Pool;
|
||||||
|
|
||||||
|
$responses = Http::pool(fn (Pool $pool) => [
|
||||||
|
$pool->as('users')->get('https://api.example.com/users'),
|
||||||
|
$pool->as('posts')->get('https://api.example.com/posts'),
|
||||||
|
$pool->as('comments')->get('https://api.example.com/comments'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$users = $responses['users']->json();
|
||||||
|
$posts = $responses['posts']->json();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fake HTTP Calls in Tests
|
||||||
|
|
||||||
|
Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
it('syncs user from API', function () {
|
||||||
|
$service = new UserSyncService;
|
||||||
|
$service->sync(1); // Hits the real API
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
it('syncs user from API', function () {
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'api.example.com/users/1' => Http::response([
|
||||||
|
'name' => 'John Doe',
|
||||||
|
'email' => 'john@example.com',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = new UserSyncService;
|
||||||
|
$service->sync(1);
|
||||||
|
|
||||||
|
Http::assertSent(function (Request $request) {
|
||||||
|
return $request->url() === 'https://api.example.com/users/1';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Test failure scenarios too:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Http::fake([
|
||||||
|
'api.example.com/*' => Http::failedConnection(),
|
||||||
|
]);
|
||||||
|
```
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Mail Best Practices
|
||||||
|
|
||||||
|
## Implement `ShouldQueue` on the Mailable Class
|
||||||
|
|
||||||
|
Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it.
|
||||||
|
|
||||||
|
## Use `afterCommit()` on Mailables Inside Transactions
|
||||||
|
|
||||||
|
A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor.
|
||||||
|
|
||||||
|
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
|
||||||
|
|
||||||
|
`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
|
||||||
|
|
||||||
|
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
|
||||||
|
|
||||||
|
Correct: `Mail::assertQueued(OrderShipped::class);`
|
||||||
|
|
||||||
|
## Use Markdown Mailables for Transactional Emails
|
||||||
|
|
||||||
|
Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag.
|
||||||
|
|
||||||
|
## Separate Content Tests from Sending Tests
|
||||||
|
|
||||||
|
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
|
||||||
|
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
|
||||||
|
Don't mix them — it conflates concerns and makes tests brittle.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Migration Best Practices
|
||||||
|
|
||||||
|
## Generate Migrations with Artisan
|
||||||
|
|
||||||
|
Always use `php artisan make:migration` for consistent naming and timestamps.
|
||||||
|
|
||||||
|
Incorrect (manually created file):
|
||||||
|
```php
|
||||||
|
// database/migrations/posts_migration.php ← wrong naming, no timestamp
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (Artisan-generated):
|
||||||
|
```bash
|
||||||
|
php artisan make:migration create_posts_table
|
||||||
|
php artisan make:migration add_slug_to_posts_table
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `constrained()` for Foreign Keys
|
||||||
|
|
||||||
|
Automatic naming and referential integrity.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
|
||||||
|
// Non-standard names
|
||||||
|
$table->foreignId('author_id')->constrained('users');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Never Modify Deployed Migrations
|
||||||
|
|
||||||
|
Once a migration has run in production, treat it as immutable. Create a new migration to change the table.
|
||||||
|
|
||||||
|
Incorrect (editing a deployed migration):
|
||||||
|
```php
|
||||||
|
// 2024_01_01_create_posts_table.php — already in production
|
||||||
|
$table->string('slug')->unique(); // ← added after deployment
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (new migration to alter):
|
||||||
|
```php
|
||||||
|
// 2024_03_15_add_slug_to_posts_table.php
|
||||||
|
Schema::table('posts', function (Blueprint $table) {
|
||||||
|
$table->string('slug')->unique()->after('title');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Add Indexes in the Migration
|
||||||
|
|
||||||
|
Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
Schema::create('orders', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained();
|
||||||
|
$table->string('status');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
Schema::create('orders', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->index();
|
||||||
|
$table->string('status')->index();
|
||||||
|
$table->timestamp('shipped_at')->nullable()->index();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mirror Defaults in Model `$attributes`
|
||||||
|
|
||||||
|
When a column has a database default, mirror it in the model so new instances have correct values before saving.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Migration
|
||||||
|
$table->string('status')->default('pending');
|
||||||
|
|
||||||
|
// Model
|
||||||
|
protected $attributes = [
|
||||||
|
'status' => 'pending',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Write Reversible `down()` Methods by Default
|
||||||
|
|
||||||
|
Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('posts', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('slug');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported.
|
||||||
|
|
||||||
|
## Keep Migrations Focused
|
||||||
|
|
||||||
|
One concern per migration. Never mix DDL (schema changes) and DML (data manipulation).
|
||||||
|
|
||||||
|
Incorrect (partial failure creates unrecoverable state):
|
||||||
|
```php
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('settings', function (Blueprint $table) { ... });
|
||||||
|
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (separate migrations):
|
||||||
|
```php
|
||||||
|
// Migration 1: create_settings_table
|
||||||
|
Schema::create('settings', function (Blueprint $table) { ... });
|
||||||
|
|
||||||
|
// Migration 2: seed_default_settings
|
||||||
|
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
||||||
|
```
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Queue & Job Best Practices
|
||||||
|
|
||||||
|
## Set `retry_after` Greater Than `timeout`
|
||||||
|
|
||||||
|
If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution.
|
||||||
|
|
||||||
|
Incorrect (`retry_after` ≤ `timeout`):
|
||||||
|
```php
|
||||||
|
class ProcessReport implements ShouldQueue
|
||||||
|
{
|
||||||
|
public $timeout = 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
// config/queue.php — retry_after: 90 ← job retried while still running!
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (`retry_after` > `timeout`):
|
||||||
|
```php
|
||||||
|
class ProcessReport implements ShouldQueue
|
||||||
|
{
|
||||||
|
public $timeout = 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
// config/queue.php — retry_after: 180 ← safely longer than any job timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Exponential Backoff
|
||||||
|
|
||||||
|
Use progressively longer delays between retries to avoid hammering failing services.
|
||||||
|
|
||||||
|
Incorrect (fixed retry interval):
|
||||||
|
```php
|
||||||
|
class SyncWithStripe implements ShouldQueue
|
||||||
|
{
|
||||||
|
public $tries = 3;
|
||||||
|
// Default: retries immediately, overwhelming the API
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct (exponential backoff):
|
||||||
|
```php
|
||||||
|
class SyncWithStripe implements ShouldQueue
|
||||||
|
{
|
||||||
|
public $tries = 3;
|
||||||
|
public $backoff = [1, 5, 10];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implement `ShouldBeUnique`
|
||||||
|
|
||||||
|
Prevent duplicate job processing.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class GenerateInvoice implements ShouldQueue, ShouldBeUnique
|
||||||
|
{
|
||||||
|
public function uniqueId(): string
|
||||||
|
{
|
||||||
|
return $this->order->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public $uniqueFor = 3600;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Always Implement `failed()`
|
||||||
|
|
||||||
|
Handle errors explicitly — don't rely on silent failure.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function failed(?Throwable $exception): void
|
||||||
|
{
|
||||||
|
$this->podcast->update(['status' => 'failed']);
|
||||||
|
Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limit External API Calls in Jobs
|
||||||
|
|
||||||
|
Use `RateLimited` middleware to throttle jobs calling third-party APIs.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new RateLimited('external-api')];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Batch Related Jobs
|
||||||
|
|
||||||
|
Use `Bus::batch()` when jobs should succeed or fail together.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Bus::batch([
|
||||||
|
new ImportCsvChunk($chunk1),
|
||||||
|
new ImportCsvChunk($chunk2),
|
||||||
|
])
|
||||||
|
->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
|
||||||
|
->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed'))
|
||||||
|
->dispatch();
|
||||||
|
```
|
||||||
|
|
||||||
|
## `retryUntil()` Needs `$tries = 0`
|
||||||
|
|
||||||
|
When using time-based retry limits, set `$tries = 0` to avoid premature failure.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public $tries = 0;
|
||||||
|
|
||||||
|
public function retryUntil(): \DateTimeInterface
|
||||||
|
{
|
||||||
|
return now()->addHours(4);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
|
||||||
|
|
||||||
|
`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
|
||||||
|
|
||||||
|
```php
|
||||||
|
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
|
||||||
|
{
|
||||||
|
// Lock releases when processing begins, not when it finishes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Horizon for Complex Queue Scenarios
|
||||||
|
|
||||||
|
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/horizon.php
|
||||||
|
'environments' => [
|
||||||
|
'production' => [
|
||||||
|
'supervisor-1' => [
|
||||||
|
'connection' => 'redis',
|
||||||
|
'queue' => ['high', 'default', 'low'],
|
||||||
|
'balance' => 'auto',
|
||||||
|
'minProcesses' => 1,
|
||||||
|
'maxProcesses' => 10,
|
||||||
|
'tries' => 3,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
```
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Routing & Controllers Best Practices
|
||||||
|
|
||||||
|
## Use Implicit Route Model Binding
|
||||||
|
|
||||||
|
Let Laravel resolve models automatically from route parameters.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
public function show(int $id)
|
||||||
|
{
|
||||||
|
$post = Post::findOrFail($id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
public function show(Post $post)
|
||||||
|
{
|
||||||
|
return view('posts.show', ['post' => $post]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Scoped Bindings for Nested Resources
|
||||||
|
|
||||||
|
Enforce parent-child relationships automatically.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
|
||||||
|
// $post is automatically scoped to $user
|
||||||
|
})->scopeBindings();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Resource Controllers
|
||||||
|
|
||||||
|
Use `Route::resource()` or `apiResource()` for RESTful endpoints.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Route::resource('posts', PostController::class);
|
||||||
|
// In routes/api.php — the /api prefix is applied automatically
|
||||||
|
Route::apiResource('posts', Api\PostController::class);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keep Controllers Thin
|
||||||
|
|
||||||
|
Aim for under 10 lines per method. Extract business logic to action or service classes.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([...]);
|
||||||
|
if ($request->hasFile('image')) {
|
||||||
|
$request->file('image')->move(public_path('images'));
|
||||||
|
}
|
||||||
|
$post = Post::create($validated);
|
||||||
|
$post->tags()->sync($validated['tags']);
|
||||||
|
event(new PostCreated($post));
|
||||||
|
return redirect()->route('posts.show', $post);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
public function store(StorePostRequest $request, CreatePostAction $create)
|
||||||
|
{
|
||||||
|
$post = $create->execute($request->validated());
|
||||||
|
|
||||||
|
return redirect()->route('posts.show', $post);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type-Hint Form Requests
|
||||||
|
|
||||||
|
Type-hinting Form Requests triggers automatic validation and authorization before the method executes.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => ['required', 'max:255'],
|
||||||
|
'body' => ['required'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Post::create($validated);
|
||||||
|
|
||||||
|
return redirect()->route('posts.index');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
public function store(StorePostRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Post::create($request->validated());
|
||||||
|
|
||||||
|
return redirect()->route('posts.index');
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Task Scheduling Best Practices
|
||||||
|
|
||||||
|
## Use `withoutOverlapping()` on Variable-Duration Tasks
|
||||||
|
|
||||||
|
Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion.
|
||||||
|
|
||||||
|
## Use `onOneServer()` on Multi-Server Deployments
|
||||||
|
|
||||||
|
Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached).
|
||||||
|
|
||||||
|
## Use `runInBackground()` for Concurrent Long Tasks
|
||||||
|
|
||||||
|
By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes.
|
||||||
|
|
||||||
|
## Use `environments()` to Restrict Tasks
|
||||||
|
|
||||||
|
Prevent accidental execution of production-only tasks (billing, reporting) on staging.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Schedule::command('billing:charge')->monthly()->environments(['production']);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `takeUntilTimeout()` for Time-Bounded Processing
|
||||||
|
|
||||||
|
A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time.
|
||||||
|
|
||||||
|
## Use Schedule Groups for Shared Configuration
|
||||||
|
|
||||||
|
Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Schedule::daily()
|
||||||
|
->onOneServer()
|
||||||
|
->timezone('America/New_York')
|
||||||
|
->group(function () {
|
||||||
|
Schedule::command('emails:send --force');
|
||||||
|
Schedule::command('emails:prune');
|
||||||
|
});
|
||||||
|
```
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# Security Best Practices
|
||||||
|
|
||||||
|
## Mass Assignment Protection
|
||||||
|
|
||||||
|
Every model must define `$fillable` (whitelist) or `$guarded` (blacklist).
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
class User extends Model
|
||||||
|
{
|
||||||
|
protected $guarded = []; // All fields are mass assignable
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
class User extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
'password',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Never use `$guarded = []` on models that accept user input.
|
||||||
|
|
||||||
|
## Authorize Every Action
|
||||||
|
|
||||||
|
Use policies or gates in controllers. Never skip authorization.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
public function update(UpdatePostRequest $request, Post $post)
|
||||||
|
{
|
||||||
|
$post->update($request->validated());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
public function update(UpdatePostRequest $request, Post $post)
|
||||||
|
{
|
||||||
|
Gate::authorize('update', $post);
|
||||||
|
|
||||||
|
$post->update($request->validated());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via Form Request:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()->can('update', $this->route('post'));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prevent SQL Injection
|
||||||
|
|
||||||
|
Always use parameter binding. Never interpolate user input into queries.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
DB::select("SELECT * FROM users WHERE name = '{$request->name}'");
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
User::where('name', $request->name)->get();
|
||||||
|
|
||||||
|
// Raw expressions with bindings
|
||||||
|
User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Escape Output to Prevent XSS
|
||||||
|
|
||||||
|
Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```blade
|
||||||
|
{!! $user->bio !!}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```blade
|
||||||
|
{{ $user->bio }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSRF Protection
|
||||||
|
|
||||||
|
Include `@csrf` in all POST/PUT/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```blade
|
||||||
|
<form method="POST" action="/posts">
|
||||||
|
<input type="text" name="title">
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```blade
|
||||||
|
<form method="POST" action="/posts">
|
||||||
|
@csrf
|
||||||
|
<input type="text" name="title">
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limit Auth and API Routes
|
||||||
|
|
||||||
|
Apply `throttle` middleware to authentication and API routes.
|
||||||
|
|
||||||
|
```php
|
||||||
|
RateLimiter::for('login', function (Request $request) {
|
||||||
|
return Limit::perMinute(5)->by($request->ip());
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::post('/login', LoginController::class)->middleware('throttle:login');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validate File Uploads
|
||||||
|
|
||||||
|
Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Store with generated filenames:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$path = $request->file('avatar')->store('avatars', 'public');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keep Secrets Out of Code
|
||||||
|
|
||||||
|
Never commit `.env`. Access secrets via `config()` only.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
$key = env('API_KEY');
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
// config/services.php
|
||||||
|
'api_key' => env('API_KEY'),
|
||||||
|
|
||||||
|
// In application code
|
||||||
|
$key = config('services.api_key');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audit Dependencies
|
||||||
|
|
||||||
|
Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer audit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Encrypt Sensitive Database Fields
|
||||||
|
|
||||||
|
Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
class Integration extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'api_key' => 'string',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
class Integration extends Model
|
||||||
|
{
|
||||||
|
protected $hidden = ['api_key', 'api_secret'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'api_key' => 'encrypted',
|
||||||
|
'api_secret' => 'encrypted',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Conventions & Style
|
||||||
|
|
||||||
|
## Follow Laravel Naming Conventions
|
||||||
|
|
||||||
|
| What | Convention | Good | Bad |
|
||||||
|
|------|-----------|------|-----|
|
||||||
|
| Controller | singular | `ArticleController` | `ArticlesController` |
|
||||||
|
| Model | singular | `User` | `Users` |
|
||||||
|
| Table | plural, snake_case | `article_comments` | `articleComments` |
|
||||||
|
| Pivot table | singular alphabetical | `article_user` | `user_article` |
|
||||||
|
| Column | snake_case, no model name | `meta_title` | `article_meta_title` |
|
||||||
|
| Foreign key | singular model + `_id` | `article_id` | `articles_id` |
|
||||||
|
| Route | plural | `articles/1` | `article/1` |
|
||||||
|
| Route name | snake_case with dots | `users.show_active` | `users.show-active` |
|
||||||
|
| Method | camelCase | `getAll` | `get_all` |
|
||||||
|
| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` |
|
||||||
|
| Collection | descriptive, plural | `$activeUsers` | `$data` |
|
||||||
|
| Object | descriptive, singular | `$activeUser` | `$users` |
|
||||||
|
| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` |
|
||||||
|
| Config | snake_case | `google_calendar.php` | `googleCalendar.php` |
|
||||||
|
| Enum | singular | `UserType` | `UserTypes` |
|
||||||
|
|
||||||
|
## Prefer Shorter Readable Syntax
|
||||||
|
|
||||||
|
| Verbose | Shorter |
|
||||||
|
|---------|---------|
|
||||||
|
| `Session::get('cart')` | `session('cart')` |
|
||||||
|
| `$request->session()->get('cart')` | `session('cart')` |
|
||||||
|
| `$request->input('name')` | `$request->name` |
|
||||||
|
| `return Redirect::back()` | `return back()` |
|
||||||
|
| `Carbon::now()` | `now()` |
|
||||||
|
| `App::make('Class')` | `app('Class')` |
|
||||||
|
| `->where('column', '=', 1)` | `->where('column', 1)` |
|
||||||
|
| `->orderBy('created_at', 'desc')` | `->latest()` |
|
||||||
|
| `->orderBy('created_at', 'asc')` | `->oldest()` |
|
||||||
|
| `->first()->name` | `->value('name')` |
|
||||||
|
|
||||||
|
## Use Laravel String & Array Helpers
|
||||||
|
|
||||||
|
Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them.
|
||||||
|
|
||||||
|
Strings — use `Str` and fluent `Str::of()` over raw PHP:
|
||||||
|
```php
|
||||||
|
// Incorrect
|
||||||
|
$slug = strtolower(str_replace(' ', '-', $title));
|
||||||
|
$short = substr($text, 0, 100) . '...';
|
||||||
|
$class = substr(strrchr('App\Models\User', '\\'), 1);
|
||||||
|
|
||||||
|
// Correct
|
||||||
|
$slug = Str::slug($title);
|
||||||
|
$short = Str::limit($text, 100);
|
||||||
|
$class = class_basename('App\Models\User');
|
||||||
|
```
|
||||||
|
|
||||||
|
Fluent strings — chain operations for complex transformations:
|
||||||
|
```php
|
||||||
|
// Incorrect
|
||||||
|
$result = strtolower(trim(str_replace('_', '-', $input)));
|
||||||
|
|
||||||
|
// Correct
|
||||||
|
$result = Str::of($input)->trim()->replace('_', '-')->lower();
|
||||||
|
```
|
||||||
|
|
||||||
|
Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`.
|
||||||
|
|
||||||
|
Arrays — use `Arr` over raw PHP:
|
||||||
|
```php
|
||||||
|
// Incorrect
|
||||||
|
$name = isset($array['user']['name']) ? $array['user']['name'] : 'default';
|
||||||
|
|
||||||
|
// Correct
|
||||||
|
$name = Arr::get($array, 'user.name', 'default');
|
||||||
|
```
|
||||||
|
|
||||||
|
Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`.
|
||||||
|
|
||||||
|
Numbers — use `Number` for display formatting:
|
||||||
|
```php
|
||||||
|
Number::format(1000000); // "1,000,000"
|
||||||
|
Number::currency(1500, 'USD'); // "$1,500.00"
|
||||||
|
Number::abbreviate(1000000); // "1M"
|
||||||
|
Number::fileSize(1024 * 1024); // "1 MB"
|
||||||
|
Number::percentage(75.5); // "75.5%"
|
||||||
|
```
|
||||||
|
|
||||||
|
URIs — use `Uri` for URL manipulation:
|
||||||
|
```php
|
||||||
|
$uri = Uri::of('https://example.com/search')
|
||||||
|
->withQuery(['q' => 'laravel', 'page' => 1]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining.
|
||||||
|
|
||||||
|
Use `search-docs` for the full list of available methods — these helpers are extensive.
|
||||||
|
|
||||||
|
## No Inline JS/CSS in Blade
|
||||||
|
|
||||||
|
Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```blade
|
||||||
|
let article = `{{ json_encode($article) }}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```blade
|
||||||
|
<button class="js-fav-article" data-article='@json($article)'>{{ $article->name }}</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass data to JS via data attributes or use a dedicated PHP-to-JS package.
|
||||||
|
|
||||||
|
## No Unnecessary Comments
|
||||||
|
|
||||||
|
Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
// Check if there are any joins
|
||||||
|
if (count((array) $builder->getQuery()->joins) > 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
if ($this->hasJoins())
|
||||||
|
```
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Testing Best Practices
|
||||||
|
|
||||||
|
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
|
||||||
|
|
||||||
|
`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
|
||||||
|
|
||||||
|
## Use Model Assertions Over Raw Database Assertions
|
||||||
|
|
||||||
|
Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);`
|
||||||
|
|
||||||
|
Correct: `$this->assertModelExists($user);`
|
||||||
|
|
||||||
|
More expressive, type-safe, and fails with clearer messages.
|
||||||
|
|
||||||
|
## Use Factory States and Sequences
|
||||||
|
|
||||||
|
Named states make tests self-documenting. Sequences eliminate repetitive setup.
|
||||||
|
|
||||||
|
Incorrect: `User::factory()->create(['email_verified_at' => null]);`
|
||||||
|
|
||||||
|
Correct: `User::factory()->unverified()->create();`
|
||||||
|
|
||||||
|
## Use `Exceptions::fake()` to Assert Exception Reporting
|
||||||
|
|
||||||
|
Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally.
|
||||||
|
|
||||||
|
## Call `Event::fake()` After Factory Setup
|
||||||
|
|
||||||
|
Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models.
|
||||||
|
|
||||||
|
Incorrect: `Event::fake(); $user = User::factory()->create();`
|
||||||
|
|
||||||
|
Correct: `$user = User::factory()->create(); Event::fake();`
|
||||||
|
|
||||||
|
## Use `recycle()` to Share Relationship Instances Across Factories
|
||||||
|
|
||||||
|
Without `recycle()`, nested factories create separate instances of the same conceptual entity.
|
||||||
|
|
||||||
|
```php
|
||||||
|
Ticket::factory()
|
||||||
|
->recycle(Airline::factory()->create())
|
||||||
|
->create();
|
||||||
|
```
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Validation & Forms Best Practices
|
||||||
|
|
||||||
|
## Use Form Request Classes
|
||||||
|
|
||||||
|
Extract validation from controllers into dedicated Form Request classes.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'title' => 'required|max:255',
|
||||||
|
'body' => 'required',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
public function store(StorePostRequest $request)
|
||||||
|
{
|
||||||
|
Post::create($request->validated());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Array vs. String Notation for Rules
|
||||||
|
|
||||||
|
Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Preferred for new code
|
||||||
|
'email' => ['required', 'email', Rule::unique('users')],
|
||||||
|
|
||||||
|
// Follow existing convention if the project uses string notation
|
||||||
|
'email' => 'required|email|unique:users',
|
||||||
|
```
|
||||||
|
|
||||||
|
## Always Use `validated()`
|
||||||
|
|
||||||
|
Get only validated data. Never use `$request->all()` for mass operations.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
```php
|
||||||
|
Post::create($request->all());
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
```php
|
||||||
|
Post::create($request->validated());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use `Rule::when()` for Conditional Validation
|
||||||
|
|
||||||
|
```php
|
||||||
|
'company_name' => [
|
||||||
|
Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']),
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use the `after()` Method for Custom Validation
|
||||||
|
|
||||||
|
Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function after(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
function (Validator $validator) {
|
||||||
|
if ($this->quantity > Product::find($this->product_id)?->stock) {
|
||||||
|
$validator->errors()->add('quantity', 'Not enough stock.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
name: tailwindcss-development
|
||||||
|
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tailwind CSS Development
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||||
|
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||||
|
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||||
|
|
||||||
|
## Tailwind CSS v4 Specifics
|
||||||
|
|
||||||
|
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||||
|
- `corePlugins` is not supported in Tailwind v4.
|
||||||
|
|
||||||
|
### CSS-First Configuration
|
||||||
|
|
||||||
|
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||||
|
|
||||||
|
<!-- CSS-First Config -->
|
||||||
|
```css
|
||||||
|
@theme {
|
||||||
|
--color-brand: oklch(0.72 0.11 178);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Syntax
|
||||||
|
|
||||||
|
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
||||||
|
|
||||||
|
<!-- v4 Import Syntax -->
|
||||||
|
```diff
|
||||||
|
- @tailwind base;
|
||||||
|
- @tailwind components;
|
||||||
|
- @tailwind utilities;
|
||||||
|
+ @import "tailwindcss";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replaced Utilities
|
||||||
|
|
||||||
|
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
||||||
|
|
||||||
|
| Deprecated | Replacement |
|
||||||
|
|------------|-------------|
|
||||||
|
| bg-opacity-* | bg-black/* |
|
||||||
|
| text-opacity-* | text-black/* |
|
||||||
|
| border-opacity-* | border-black/* |
|
||||||
|
| divide-opacity-* | divide-black/* |
|
||||||
|
| ring-opacity-* | ring-black/* |
|
||||||
|
| placeholder-opacity-* | placeholder-black/* |
|
||||||
|
| flex-shrink-* | shrink-* |
|
||||||
|
| flex-grow-* | grow-* |
|
||||||
|
| overflow-ellipsis | text-ellipsis |
|
||||||
|
| decoration-slice | box-decoration-slice |
|
||||||
|
| decoration-clone | box-decoration-clone |
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
Use `gap` utilities instead of margins for spacing between siblings:
|
||||||
|
|
||||||
|
<!-- Gap Utilities -->
|
||||||
|
```html
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div>Item 1</div>
|
||||||
|
<div>Item 2</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dark Mode
|
||||||
|
|
||||||
|
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||||
|
|
||||||
|
<!-- Dark Mode -->
|
||||||
|
```html
|
||||||
|
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||||
|
Content adapts to color scheme
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Flexbox Layout
|
||||||
|
|
||||||
|
<!-- Flexbox Layout -->
|
||||||
|
```html
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>Left content</div>
|
||||||
|
<div>Right content</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grid Layout
|
||||||
|
|
||||||
|
<!-- Grid Layout -->
|
||||||
|
```html
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div>Card 1</div>
|
||||||
|
<div>Card 2</div>
|
||||||
|
<div>Card 3</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
||||||
|
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||||
|
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||||
|
- Using margins for spacing between siblings instead of gap utilities
|
||||||
|
- Forgetting to add dark mode variants when the project uses dark mode
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[{compose,docker-compose}.{yml,yaml}]
|
||||||
|
indent_size = 4
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
APP_NAME=AndyTranscribe
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=http://localhost:8000
|
||||||
|
|
||||||
|
APP_LOCALE=en
|
||||||
|
APP_FALLBACK_LOCALE=en
|
||||||
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
|
APP_MAINTENANCE_DRIVER=file
|
||||||
|
# APP_MAINTENANCE_STORE=database
|
||||||
|
|
||||||
|
# PHP_CLI_SERVER_WORKERS=4
|
||||||
|
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_STACK=single
|
||||||
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
DB_CONNECTION=sqlite
|
||||||
|
# DB_HOST=127.0.0.1
|
||||||
|
# DB_PORT=3306
|
||||||
|
# DB_DATABASE=laravel
|
||||||
|
# DB_USERNAME=root
|
||||||
|
# DB_PASSWORD=
|
||||||
|
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
SESSION_ENCRYPT=false
|
||||||
|
SESSION_PATH=/
|
||||||
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
|
BROADCAST_CONNECTION=log
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
CACHE_STORE=database
|
||||||
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
|
REDIS_CLIENT=phpredis
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
MAIL_MAILER=log
|
||||||
|
MAIL_SCHEME=null
|
||||||
|
MAIL_HOST=127.0.0.1
|
||||||
|
MAIL_PORT=2525
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
AWS_BUCKET=
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
# AndyTranscribe / Laravel AI
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_URL=https://api.openai.com/v1
|
||||||
|
LOCAL_WHISPER_URL=http://localhost:8000/v1
|
||||||
|
LOCAL_WHISPER_API_KEY=not-needed
|
||||||
|
LOCAL_WHISPER_MODEL=Systran/faster-whisper-base
|
||||||
|
REMOTE_WHISPER_MODEL=Systran/faster-whisper-base
|
||||||
|
TRANSCRIPTION_TIMEOUT=600
|
||||||
@@ -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
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.backup
|
||||||
|
.env.production
|
||||||
|
.phpactor.json
|
||||||
|
.phpunit.result.cache
|
||||||
|
/.codex
|
||||||
|
/.cursor/
|
||||||
|
/.idea
|
||||||
|
/.nova
|
||||||
|
/.phpunit.cache
|
||||||
|
/.vscode
|
||||||
|
/.zed
|
||||||
|
/auth.json
|
||||||
|
/node_modules
|
||||||
|
/public/build
|
||||||
|
/public/fonts-manifest.dev.json
|
||||||
|
/public/hot
|
||||||
|
/public/storage
|
||||||
|
/storage/*.key
|
||||||
|
/storage/pail
|
||||||
|
/vendor
|
||||||
|
_ide_helper.php
|
||||||
|
Homestead.json
|
||||||
|
Homestead.yaml
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"laravel-boost": {
|
||||||
|
"command": "php",
|
||||||
|
"args": [
|
||||||
|
"artisan",
|
||||||
|
"boost:mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<laravel-boost-guidelines>
|
||||||
|
=== foundation rules ===
|
||||||
|
|
||||||
|
# Laravel Boost Guidelines
|
||||||
|
|
||||||
|
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||||
|
|
||||||
|
## Foundational Context
|
||||||
|
|
||||||
|
This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.
|
||||||
|
|
||||||
|
Before relying on a package's API, confirm its installed version:
|
||||||
|
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
|
||||||
|
- JS packages: check `package.json` for the installed versions.
|
||||||
|
|
||||||
|
## Skills Activation
|
||||||
|
|
||||||
|
This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||||
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
|
## Verification Scripts
|
||||||
|
|
||||||
|
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||||
|
|
||||||
|
## Application Structure & Architecture
|
||||||
|
|
||||||
|
- Stick to existing directory structure; don't create new base folders without approval.
|
||||||
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
|
## Frontend Bundling
|
||||||
|
|
||||||
|
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||||
|
|
||||||
|
## Documentation Files
|
||||||
|
|
||||||
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
|
## Replies
|
||||||
|
|
||||||
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
|
|
||||||
|
=== boost rules ===
|
||||||
|
|
||||||
|
# Laravel Boost
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
|
||||||
|
- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
|
||||||
|
- Use `database-schema` to inspect table structure before writing migrations or models.
|
||||||
|
- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
|
||||||
|
- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
|
||||||
|
|
||||||
|
## Searching Documentation (IMPORTANT)
|
||||||
|
|
||||||
|
- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
|
||||||
|
- Pass a `packages` array to scope results when you know which packages are relevant.
|
||||||
|
- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
|
||||||
|
- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
|
||||||
|
|
||||||
|
### Search Syntax
|
||||||
|
|
||||||
|
1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
|
||||||
|
2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
|
||||||
|
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
|
||||||
|
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
|
||||||
|
|
||||||
|
## Project Rules
|
||||||
|
|
||||||
|
- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it.
|
||||||
|
- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo.
|
||||||
|
|
||||||
|
## Artisan
|
||||||
|
|
||||||
|
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||||
|
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
||||||
|
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
||||||
|
|
||||||
|
## Tinker
|
||||||
|
|
||||||
|
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
|
||||||
|
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
|
||||||
|
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
|
||||||
|
|
||||||
|
=== php rules ===
|
||||||
|
|
||||||
|
# PHP
|
||||||
|
|
||||||
|
- Always use curly braces for control structures, even for single-line bodies.
|
||||||
|
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
|
||||||
|
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
|
||||||
|
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
|
||||||
|
- Use array shape type definitions in PHPDoc blocks.
|
||||||
|
|
||||||
|
=== deployments rules ===
|
||||||
|
|
||||||
|
# Deployment
|
||||||
|
|
||||||
|
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||||
|
|
||||||
|
=== laravel/core rules ===
|
||||||
|
|
||||||
|
# Do Things the Laravel Way
|
||||||
|
|
||||||
|
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
|
||||||
|
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||||
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
|
### Model Creation
|
||||||
|
|
||||||
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
|
||||||
|
|
||||||
|
## APIs & Eloquent Resources
|
||||||
|
|
||||||
|
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||||
|
|
||||||
|
## URL Generation
|
||||||
|
|
||||||
|
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
|
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
|
## Vite Error
|
||||||
|
|
||||||
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||||
|
|
||||||
|
=== pint/core rules ===
|
||||||
|
|
||||||
|
# Laravel Pint Code Formatter
|
||||||
|
|
||||||
|
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
|
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||||
|
|
||||||
|
=== phpunit/core rules ===
|
||||||
|
|
||||||
|
# PHPUnit
|
||||||
|
|
||||||
|
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
|
||||||
|
- If you see a test using "Pest", convert it to PHPUnit.
|
||||||
|
- Every time a test has been updated, run that singular test.
|
||||||
|
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
||||||
|
- Tests should cover all happy paths, failure paths, and edge cases.
|
||||||
|
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||||
|
- To run all tests: `php artisan test --compact`.
|
||||||
|
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||||
|
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||||
|
|
||||||
|
</laravel-boost-guidelines>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreRecordingRequest;
|
||||||
|
use App\Models\Recording;
|
||||||
|
use App\Services\Mp3MetadataService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class RecordingController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of recordings.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$query = Recording::query()->latest();
|
||||||
|
|
||||||
|
if ($search = $request->string('q')->trim()->toString()) {
|
||||||
|
$query->where(function ($builder) use ($search) {
|
||||||
|
$builder->where('title', 'like', "%{$search}%")
|
||||||
|
->orWhere('artist', 'like', "%{$search}%")
|
||||||
|
->orWhere('album', 'like', "%{$search}%")
|
||||||
|
->orWhere('original_filename', 'like', "%{$search}%")
|
||||||
|
->orWhere('transcript', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$recordings = $query->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return view('recordings.index', compact('recordings', 'search'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the upload form.
|
||||||
|
*/
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('recordings.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly uploaded recording.
|
||||||
|
*/
|
||||||
|
public function store(StoreRecordingRequest $request, Mp3MetadataService $metadata): RedirectResponse
|
||||||
|
{
|
||||||
|
$file = $request->file('audio');
|
||||||
|
$path = $file->store('recordings', 'local');
|
||||||
|
$absolutePath = Storage::disk('local')->path($path);
|
||||||
|
$tags = $metadata->extract($absolutePath);
|
||||||
|
|
||||||
|
$title = $request->string('title')->trim()->toString()
|
||||||
|
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
|
||||||
|
|
||||||
|
$recording = Recording::create([
|
||||||
|
'title' => $title,
|
||||||
|
'original_filename' => $file->getClientOriginalName(),
|
||||||
|
'file_path' => $path,
|
||||||
|
'duration_seconds' => $tags['duration_seconds'],
|
||||||
|
'recorded_at' => $tags['recorded_at'],
|
||||||
|
'artist' => $tags['artist'],
|
||||||
|
'album' => $tags['album'],
|
||||||
|
'file_size_bytes' => $file->getSize() ?: 0,
|
||||||
|
'transcription_status' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('recordings.show', $recording)
|
||||||
|
->with('success', 'Recording uploaded successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified recording.
|
||||||
|
*/
|
||||||
|
public function show(Recording $recording): View
|
||||||
|
{
|
||||||
|
return view('recordings.show', compact('recording'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified recording.
|
||||||
|
*/
|
||||||
|
public function destroy(Recording $recording): RedirectResponse
|
||||||
|
{
|
||||||
|
$recording->deleteFile();
|
||||||
|
$recording->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('recordings.index')
|
||||||
|
->with('success', 'Recording deleted.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\TranscribeRecordingRequest;
|
||||||
|
use App\Jobs\TranscribeRecording;
|
||||||
|
use App\Models\Recording;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
|
class TranscribeController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Queue transcription for the recording with the chosen engine.
|
||||||
|
*/
|
||||||
|
public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse
|
||||||
|
{
|
||||||
|
if (in_array($recording->transcription_status, ['processing'], true)) {
|
||||||
|
return back()->with('error', 'Transcription is already in progress.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$driver = $request->validated('driver');
|
||||||
|
|
||||||
|
$recording->update([
|
||||||
|
'transcription_driver' => $driver,
|
||||||
|
'ollama_url' => $driver === 'ollama' ? rtrim($request->validated('ollama_url'), '/') : null,
|
||||||
|
'transcription_status' => 'pending',
|
||||||
|
'transcript' => null,
|
||||||
|
'transcribed_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
TranscribeRecording::dispatch($recording->fresh());
|
||||||
|
|
||||||
|
return back()->with('success', 'Transcription started. Refresh in a moment to see the result.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreRecordingRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'audio' => ['required', 'file', 'mimes:mp3,mpeg', 'max:102400'],
|
||||||
|
'title' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'audio.required' => 'Please choose an MP3 file to upload.',
|
||||||
|
'audio.mimes' => 'Only MP3 files are supported.',
|
||||||
|
'audio.max' => 'The audio file may not be larger than 100 MB.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class TranscribeRecordingRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'driver' => ['required', Rule::in(['cloud', 'local', 'ollama'])],
|
||||||
|
'ollama_url' => [
|
||||||
|
Rule::requiredIf(fn () => $this->input('driver') === 'ollama'),
|
||||||
|
'nullable',
|
||||||
|
'url',
|
||||||
|
'regex:/^https?:\/\//i',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'driver.required' => 'Choose a transcription engine.',
|
||||||
|
'ollama_url.required' => 'Enter the URL of the Ollama host (OpenAI-compatible Whisper endpoint).',
|
||||||
|
'ollama_url.url' => 'Enter a valid URL, e.g. http://192.168.1.50:8000',
|
||||||
|
'ollama_url.regex' => 'The host URL must start with http:// or https://',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Recording;
|
||||||
|
use App\Services\TranscriptionService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class TranscribeRecording implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of seconds the job can run before timing out.
|
||||||
|
*/
|
||||||
|
public int $timeout = 600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct(public Recording $recording)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(TranscriptionService $transcription): void
|
||||||
|
{
|
||||||
|
$this->recording->update([
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$text = $transcription->transcribe($this->recording);
|
||||||
|
|
||||||
|
$this->recording->update([
|
||||||
|
'transcript' => $text,
|
||||||
|
'transcription_status' => 'done',
|
||||||
|
'transcribed_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('Transcription failed', [
|
||||||
|
'recording_id' => $this->recording->id,
|
||||||
|
'driver' => $this->recording->transcription_driver,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->recording->update([
|
||||||
|
'transcription_status' => 'failed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class Recording extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'title',
|
||||||
|
'original_filename',
|
||||||
|
'file_path',
|
||||||
|
'duration_seconds',
|
||||||
|
'recorded_at',
|
||||||
|
'artist',
|
||||||
|
'album',
|
||||||
|
'file_size_bytes',
|
||||||
|
'transcript',
|
||||||
|
'transcription_status',
|
||||||
|
'transcription_driver',
|
||||||
|
'ollama_url',
|
||||||
|
'transcribed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'recorded_at' => 'datetime',
|
||||||
|
'transcribed_at' => 'datetime',
|
||||||
|
'duration_seconds' => 'integer',
|
||||||
|
'file_size_bytes' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable duration (m:ss).
|
||||||
|
*/
|
||||||
|
protected function durationFormatted(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::get(function (): string {
|
||||||
|
if ($this->duration_seconds === null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
$minutes = intdiv($this->duration_seconds, 60);
|
||||||
|
$seconds = $this->duration_seconds % 60;
|
||||||
|
|
||||||
|
return sprintf('%d:%02d', $minutes, $seconds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute filesystem path for the stored audio file.
|
||||||
|
*/
|
||||||
|
public function absolutePath(): string
|
||||||
|
{
|
||||||
|
return Storage::disk('local')->path($this->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the audio file from storage.
|
||||||
|
*/
|
||||||
|
public function deleteFile(): void
|
||||||
|
{
|
||||||
|
if ($this->file_path && Storage::disk('local')->exists($this->file_path)) {
|
||||||
|
Storage::disk('local')->delete($this->file_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Database\Factories\UserFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
|
||||||
|
#[Fillable(['name', 'email', 'password'])]
|
||||||
|
#[Hidden(['password', 'remember_token'])]
|
||||||
|
class User extends Authenticatable
|
||||||
|
{
|
||||||
|
/** @use HasFactory<UserFactory> */
|
||||||
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email_verified_at' => 'datetime',
|
||||||
|
'password' => 'hashed',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class AppServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register any application services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use getID3;
|
||||||
|
|
||||||
|
class Mp3MetadataService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Extract ID3 and audio metadata from an MP3 file on disk.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* title: ?string,
|
||||||
|
* artist: ?string,
|
||||||
|
* album: ?string,
|
||||||
|
* duration_seconds: ?int,
|
||||||
|
* recorded_at: ?string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function extract(string $absolutePath): array
|
||||||
|
{
|
||||||
|
$analyzer = new getID3;
|
||||||
|
$info = $analyzer->analyze($absolutePath);
|
||||||
|
|
||||||
|
$tags = [];
|
||||||
|
if (isset($info['tags']['id3v2'])) {
|
||||||
|
$tags = $info['tags']['id3v2'];
|
||||||
|
} elseif (isset($info['tags']['id3v1'])) {
|
||||||
|
$tags = $info['tags']['id3v1'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = $this->firstTag($tags, 'title');
|
||||||
|
$artist = $this->firstTag($tags, 'artist');
|
||||||
|
$album = $this->firstTag($tags, 'album');
|
||||||
|
$year = $this->firstTag($tags, 'year') ?? $this->firstTag($tags, 'recording_time');
|
||||||
|
|
||||||
|
$duration = isset($info['playtime_seconds'])
|
||||||
|
? (int) round((float) $info['playtime_seconds'])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$recordedAt = null;
|
||||||
|
if (filled($year) && preg_match('/^\d{4}/', $year)) {
|
||||||
|
try {
|
||||||
|
$recordedAt = Carbon::parse($year)->toDateTimeString();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$recordedAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => $title,
|
||||||
|
'artist' => $artist,
|
||||||
|
'album' => $album,
|
||||||
|
'duration_seconds' => $duration,
|
||||||
|
'recorded_at' => $recordedAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $tags
|
||||||
|
*/
|
||||||
|
private function firstTag(array $tags, string $key): ?string
|
||||||
|
{
|
||||||
|
if (! isset($tags[$key])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $tags[$key];
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$value = $value[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filled($value) ? (string) $value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Recording;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Laravel\Ai\Transcription;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class TranscriptionService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run transcription for a recording using the selected driver.
|
||||||
|
*/
|
||||||
|
public function transcribe(Recording $recording): string
|
||||||
|
{
|
||||||
|
return match ($recording->transcription_driver) {
|
||||||
|
'cloud' => $this->viaCloud($recording),
|
||||||
|
'local' => $this->viaLocal($recording),
|
||||||
|
'ollama' => $this->viaRemoteCompatible($recording),
|
||||||
|
default => throw new RuntimeException('Unknown transcription driver: '.$recording->transcription_driver),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function viaCloud(Recording $recording): string
|
||||||
|
{
|
||||||
|
$transcript = Transcription::fromStorage($recording->file_path)
|
||||||
|
->timeout((int) config('ai.transcription_timeout', 600))
|
||||||
|
->generate('openai', 'whisper-1');
|
||||||
|
|
||||||
|
return (string) $transcript;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function viaLocal(Recording $recording): string
|
||||||
|
{
|
||||||
|
$model = config('ai.local_whisper_model', 'Systran/faster-whisper-base');
|
||||||
|
|
||||||
|
$transcript = Transcription::fromStorage($recording->file_path)
|
||||||
|
->timeout((int) config('ai.transcription_timeout', 600))
|
||||||
|
->generate('local-whisper', $model);
|
||||||
|
|
||||||
|
return (string) $transcript;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call an OpenAI-compatible /v1/audio/transcriptions endpoint at a user-supplied host URL.
|
||||||
|
*/
|
||||||
|
private function viaRemoteCompatible(Recording $recording): string
|
||||||
|
{
|
||||||
|
if (! filled($recording->ollama_url)) {
|
||||||
|
throw new RuntimeException('Ollama host URL is required for remote transcription.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = $this->normalizeBaseUrl($recording->ollama_url);
|
||||||
|
$model = config('ai.remote_whisper_model', config('ai.local_whisper_model', 'Systran/faster-whisper-base'));
|
||||||
|
$path = $recording->absolutePath();
|
||||||
|
|
||||||
|
if (! is_readable($path)) {
|
||||||
|
throw new RuntimeException('Recording audio file is not readable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::timeout((int) config('ai.transcription_timeout', 600))
|
||||||
|
->attach(
|
||||||
|
'file',
|
||||||
|
fopen($path, 'r'),
|
||||||
|
$recording->original_filename ?: basename($path),
|
||||||
|
)
|
||||||
|
->post($base.'/audio/transcriptions', [
|
||||||
|
'model' => $model,
|
||||||
|
'response_format' => 'json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Remote transcription failed (HTTP '.$response->status().'): '.$response->body()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $response->json('text');
|
||||||
|
|
||||||
|
if (! is_string($text) || $text === '') {
|
||||||
|
throw new RuntimeException('Remote transcription returned an empty transcript. Ensure the host exposes OpenAI-compatible /v1/audio/transcriptions.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a user URL to an OpenAI-style base ending in /v1.
|
||||||
|
*/
|
||||||
|
private function normalizeBaseUrl(string $url): string
|
||||||
|
{
|
||||||
|
$url = rtrim(trim($url), '/');
|
||||||
|
|
||||||
|
if (Str::endsWith($url, '/v1')) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url.'/v1';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
|
|
||||||
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
|
// Register the Composer autoloader...
|
||||||
|
require __DIR__.'/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Bootstrap Laravel and handle the command...
|
||||||
|
/** @var Application $app */
|
||||||
|
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||||
|
|
||||||
|
$status = $app->handleCommand(new ArgvInput);
|
||||||
|
|
||||||
|
exit($status);
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"cloud": false,
|
||||||
|
"guidelines": true,
|
||||||
|
"mcp": true,
|
||||||
|
"nightwatch": false,
|
||||||
|
"sail": false,
|
||||||
|
"skills": [
|
||||||
|
"infer-conventions",
|
||||||
|
"ai-sdk-development",
|
||||||
|
"laravel-best-practices",
|
||||||
|
"tailwindcss-development"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
|
->withRouting(
|
||||||
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
health: '/up',
|
||||||
|
)
|
||||||
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
//
|
||||||
|
})
|
||||||
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
$exceptions->shouldRenderJsonWhen(
|
||||||
|
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
|
||||||
|
);
|
||||||
|
})->create();
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Providers\AppServiceProvider;
|
||||||
|
|
||||||
|
return [
|
||||||
|
AppServiceProvider::class,
|
||||||
|
];
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://getcomposer.org/schema.json",
|
||||||
|
"name": "laravel/laravel",
|
||||||
|
"type": "project",
|
||||||
|
"description": "The skeleton application for the Laravel framework.",
|
||||||
|
"keywords": ["laravel", "framework"],
|
||||||
|
"license": "MIT",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.3",
|
||||||
|
"james-heinrich/getid3": "^1.9",
|
||||||
|
"laravel/ai": "^0.10.3",
|
||||||
|
"laravel/framework": "^13.17",
|
||||||
|
"laravel/tinker": "^3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"fakerphp/faker": "^1.23",
|
||||||
|
"laravel/boost": "^2.5",
|
||||||
|
"laravel/pail": "^1.2.5",
|
||||||
|
"laravel/pao": "^1.0.6",
|
||||||
|
"laravel/pint": "^1.27",
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"nunomaduro/collision": "^8.6",
|
||||||
|
"phpunit/phpunit": "^12.5.12"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/",
|
||||||
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"setup": [
|
||||||
|
"composer install",
|
||||||
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||||
|
"@php artisan key:generate",
|
||||||
|
"@php artisan migrate --force",
|
||||||
|
"npm install --ignore-scripts",
|
||||||
|
"npm run build"
|
||||||
|
],
|
||||||
|
"dev": [
|
||||||
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
"@php artisan dev"
|
||||||
|
],
|
||||||
|
"test": [
|
||||||
|
"@php artisan config:clear --ansi @no_additional_args",
|
||||||
|
"@php artisan test"
|
||||||
|
],
|
||||||
|
"post-autoload-dump": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
|
"@php artisan package:discover --ansi"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||||
|
],
|
||||||
|
"post-root-package-install": [
|
||||||
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||||
|
],
|
||||||
|
"post-create-project-cmd": [
|
||||||
|
"@php artisan key:generate --ansi",
|
||||||
|
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||||
|
"@php artisan migrate --graceful --ansi"
|
||||||
|
],
|
||||||
|
"pre-package-uninstall": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"dont-discover": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"preferred-install": "dist",
|
||||||
|
"sort-packages": true,
|
||||||
|
"allow-plugins": {
|
||||||
|
"pestphp/pest-plugin": true,
|
||||||
|
"php-http/discovery": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true
|
||||||
|
}
|
||||||
Generated
+9081
File diff suppressed because it is too large
Load Diff
+178
@@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default AI Provider Names
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which of the AI providers below should be the
|
||||||
|
| default for AI operations when no explicit provider is provided
|
||||||
|
| for the operation. This should be any provider defined below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => 'openai',
|
||||||
|
'default_for_images' => 'gemini',
|
||||||
|
'default_for_audio' => 'openai',
|
||||||
|
'default_for_transcription' => 'openai',
|
||||||
|
'default_for_embeddings' => 'openai',
|
||||||
|
'default_for_reranking' => 'cohere',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Transcription helpers (AndyTranscribe)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
'transcription_timeout' => (int) env('TRANSCRIPTION_TIMEOUT', 600),
|
||||||
|
'local_whisper_model' => env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base'),
|
||||||
|
'remote_whisper_model' => env('REMOTE_WHISPER_MODEL', env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base')),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Caching
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below you may configure caching strategies for AI related operations
|
||||||
|
| such as embedding generation. You are free to adjust these values
|
||||||
|
| based on your application's available caching stores and needs.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'caching' => [
|
||||||
|
'embeddings' => [
|
||||||
|
'cache' => false,
|
||||||
|
'store' => env('CACHE_STORE', 'database'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| AI Providers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below are each of your AI providers defined for this application. Each
|
||||||
|
| represents an AI provider and API key combination which can be used
|
||||||
|
| to perform tasks like text, image, and audio creation via agents.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'providers' => [
|
||||||
|
'anthropic' => [
|
||||||
|
'driver' => 'anthropic',
|
||||||
|
'key' => env('ANTHROPIC_API_KEY'),
|
||||||
|
'url' => env('ANTHROPIC_URL', 'https://api.anthropic.com/v1'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'azure' => [
|
||||||
|
'driver' => 'azure',
|
||||||
|
'key' => env('AZURE_OPENAI_API_KEY'),
|
||||||
|
'url' => env('AZURE_OPENAI_URL'),
|
||||||
|
'api_version' => env('AZURE_OPENAI_API_VERSION', '2025-04-01-preview'),
|
||||||
|
'deployment' => env('AZURE_OPENAI_DEPLOYMENT', 'gpt-4o'),
|
||||||
|
'embedding_deployment' => env('AZURE_OPENAI_EMBEDDING_DEPLOYMENT', 'text-embedding-3-small'),
|
||||||
|
'image_deployment' => env('AZURE_OPENAI_IMAGE_DEPLOYMENT', 'gpt-image-1'),
|
||||||
|
'store' => env('AZURE_OPENAI_STORE', true),
|
||||||
|
],
|
||||||
|
|
||||||
|
'bedrock' => [
|
||||||
|
'driver' => 'bedrock',
|
||||||
|
'region' => env('AWS_BEDROCK_REGION', 'us-east-1'),
|
||||||
|
'key' => env('AWS_BEARER_TOKEN_BEDROCK'),
|
||||||
|
'access_key_id' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret_access_key' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'session_token' => env('AWS_SESSION_TOKEN'),
|
||||||
|
'use_default_credential_provider' => env('AWS_USE_DEFAULT_CREDENTIALS', true),
|
||||||
|
'assume_role' => [
|
||||||
|
'arn' => env('AWS_BEDROCK_ASSUME_ROLE_ARN'),
|
||||||
|
'session_name' => env('AWS_BEDROCK_ASSUME_ROLE_SESSION_NAME'),
|
||||||
|
'duration_seconds' => env('AWS_BEDROCK_ASSUME_ROLE_DURATION_SECONDS'),
|
||||||
|
'external_id' => env('AWS_BEDROCK_ASSUME_ROLE_EXTERNAL_ID'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'cohere' => [
|
||||||
|
'driver' => 'cohere',
|
||||||
|
'key' => env('COHERE_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'deepseek' => [
|
||||||
|
'driver' => 'deepseek',
|
||||||
|
'key' => env('DEEPSEEK_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'eleven' => [
|
||||||
|
'driver' => 'eleven',
|
||||||
|
'key' => env('ELEVENLABS_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'gemini' => [
|
||||||
|
'driver' => 'gemini',
|
||||||
|
'key' => env('GEMINI_API_KEY'),
|
||||||
|
'url' => env('GEMINI_URL', 'https://generativelanguage.googleapis.com/v1beta/'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'groq' => [
|
||||||
|
'driver' => 'groq',
|
||||||
|
'key' => env('GROQ_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'jina' => [
|
||||||
|
'driver' => 'jina',
|
||||||
|
'key' => env('JINA_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'mistral' => [
|
||||||
|
'driver' => 'mistral',
|
||||||
|
'key' => env('MISTRAL_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'ollama' => [
|
||||||
|
'driver' => 'ollama',
|
||||||
|
'key' => env('OLLAMA_API_KEY', ''),
|
||||||
|
'url' => env('OLLAMA_URL', 'http://localhost:11434'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'openai' => [
|
||||||
|
'driver' => 'openai',
|
||||||
|
'key' => env('OPENAI_API_KEY'),
|
||||||
|
'url' => env('OPENAI_URL', 'https://api.openai.com/v1'),
|
||||||
|
'store' => env('OPENAI_STORE', true),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Local faster-whisper-server (OpenAI-compatible STT).
|
||||||
|
* Uses the openai driver so /audio/transcriptions works against a custom base URL.
|
||||||
|
*/
|
||||||
|
'local-whisper' => [
|
||||||
|
'driver' => 'openai',
|
||||||
|
'key' => env('LOCAL_WHISPER_API_KEY', 'not-needed'),
|
||||||
|
'url' => env('LOCAL_WHISPER_URL', 'http://localhost:8000/v1'),
|
||||||
|
'store' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'openai-compatible' => [
|
||||||
|
'driver' => 'openai-compatible',
|
||||||
|
'url' => env('OPENAI_COMPATIBLE_URL'),
|
||||||
|
'key' => env('OPENAI_COMPATIBLE_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'openrouter' => [
|
||||||
|
'driver' => 'openrouter',
|
||||||
|
'key' => env('OPENROUTER_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'voyageai' => [
|
||||||
|
'driver' => 'voyageai',
|
||||||
|
'key' => env('VOYAGEAI_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'xai' => [
|
||||||
|
'driver' => 'xai',
|
||||||
|
'key' => env('XAI_API_KEY'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value is the name of your application, which will be used when the
|
||||||
|
| framework needs to place the application's name in a notification or
|
||||||
|
| other UI elements where an application name needs to be displayed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'name' => env('APP_NAME', 'Laravel'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Environment
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the "environment" your application is currently
|
||||||
|
| running in. This may determine how you prefer to configure various
|
||||||
|
| services the application utilizes. Set this in your ".env" file.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'env' => env('APP_ENV', 'production'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Debug Mode
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When your application is in debug mode, detailed error messages with
|
||||||
|
| stack traces will be shown on every error that occurs within your
|
||||||
|
| application. If disabled, a simple generic error page is shown.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'debug' => (bool) env('APP_DEBUG', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application URL
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This URL is used by the console to properly generate URLs when using
|
||||||
|
| the Artisan command line tool. You should set this to the root of
|
||||||
|
| the application so that it's available within Artisan commands.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'url' => env('APP_URL', 'http://localhost'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Timezone
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the default timezone for your application, which
|
||||||
|
| will be used by the PHP date and date-time functions. The timezone
|
||||||
|
| is set to "UTC" by default as it is suitable for most use cases.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Locale Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The application locale determines the default locale that will be used
|
||||||
|
| by Laravel's translation / localization methods. This option can be
|
||||||
|
| set to any locale for which you plan to have translation strings.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'locale' => env('APP_LOCALE', 'en'),
|
||||||
|
|
||||||
|
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||||
|
|
||||||
|
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Encryption Key
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This key is utilized by Laravel's encryption services and should be set
|
||||||
|
| to a random, 32 character string to ensure that all encrypted values
|
||||||
|
| are secure. You should do this prior to deploying the application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cipher' => 'AES-256-CBC',
|
||||||
|
|
||||||
|
'key' => env('APP_KEY'),
|
||||||
|
|
||||||
|
'previous_keys' => [
|
||||||
|
...array_filter(
|
||||||
|
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Maintenance Mode Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These configuration options determine the driver used to determine and
|
||||||
|
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||||
|
| allow maintenance mode to be controlled across multiple machines.
|
||||||
|
|
|
||||||
|
| Supported drivers: "file", "cache", "array"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'maintenance' => [
|
||||||
|
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||||
|
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Defaults
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option defines the default authentication "guard" and password
|
||||||
|
| reset "broker" for your application. You may change these values
|
||||||
|
| as required, but they're a perfect start for most applications.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'defaults' => [
|
||||||
|
'guard' => env('AUTH_GUARD', 'web'),
|
||||||
|
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Next, you may define every authentication guard for your application.
|
||||||
|
| Of course, a great default configuration has been defined for you
|
||||||
|
| which utilizes session storage plus the Eloquent user provider.
|
||||||
|
|
|
||||||
|
| All authentication guards have a user provider, which defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| system used by the application. Typically, Eloquent is utilized.
|
||||||
|
|
|
||||||
|
| Supported: "session"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guards' => [
|
||||||
|
'web' => [
|
||||||
|
'driver' => 'session',
|
||||||
|
'provider' => 'users',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| User Providers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| All authentication guards have a user provider, which defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| system used by the application. Typically, Eloquent is utilized.
|
||||||
|
|
|
||||||
|
| If you have multiple user tables or models you may configure multiple
|
||||||
|
| providers to represent the model / table. These providers may then
|
||||||
|
| be assigned to any extra authentication guards you have defined.
|
||||||
|
|
|
||||||
|
| Supported: "database", "eloquent"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'providers' => [
|
||||||
|
'users' => [
|
||||||
|
'driver' => 'eloquent',
|
||||||
|
'model' => env('AUTH_MODEL', User::class),
|
||||||
|
],
|
||||||
|
|
||||||
|
// 'users' => [
|
||||||
|
// 'driver' => 'database',
|
||||||
|
// 'table' => 'users',
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Resetting Passwords
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These configuration options specify the behavior of Laravel's password
|
||||||
|
| reset functionality, including the table utilized for token storage
|
||||||
|
| and the user provider that is invoked to actually retrieve users.
|
||||||
|
|
|
||||||
|
| The expiry time is the number of minutes that each reset token will be
|
||||||
|
| considered valid. This security feature keeps tokens short-lived so
|
||||||
|
| they have less time to be guessed. You may change this as needed.
|
||||||
|
|
|
||||||
|
| The throttle setting is the number of seconds a user must wait before
|
||||||
|
| generating more password reset tokens. This prevents the user from
|
||||||
|
| quickly generating a very large amount of password reset tokens.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'passwords' => [
|
||||||
|
'users' => [
|
||||||
|
'provider' => 'users',
|
||||||
|
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||||
|
'expire' => 60,
|
||||||
|
'throttle' => 60,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Password Confirmation Timeout
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define the number of seconds before a password confirmation
|
||||||
|
| window expires and users are asked to re-enter their password via the
|
||||||
|
| confirmation screen. By default, the timeout lasts for three hours.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Cache Store
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default cache store that will be used by the
|
||||||
|
| framework. This connection is utilized if another isn't explicitly
|
||||||
|
| specified when running a cache operation inside the application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('CACHE_STORE', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Stores
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define all of the cache "stores" for your application as
|
||||||
|
| well as their drivers. You may even define multiple stores for the
|
||||||
|
| same cache driver to group types of items stored in your caches.
|
||||||
|
|
|
||||||
|
| Supported drivers: "array", "database", "file", "memcached",
|
||||||
|
| "redis", "dynamodb", "storage", "octane",
|
||||||
|
| "session", "failover", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stores' => [
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'driver' => 'array',
|
||||||
|
'serialize' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'database' => [
|
||||||
|
'driver' => 'database',
|
||||||
|
'connection' => env('DB_CACHE_CONNECTION'),
|
||||||
|
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||||
|
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||||
|
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'file' => [
|
||||||
|
'driver' => 'file',
|
||||||
|
'path' => storage_path('framework/cache/data'),
|
||||||
|
'lock_path' => storage_path('framework/cache/data'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'storage' => [
|
||||||
|
'driver' => 'storage',
|
||||||
|
'disk' => env('CACHE_STORAGE_DISK'),
|
||||||
|
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'memcached' => [
|
||||||
|
'driver' => 'memcached',
|
||||||
|
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||||
|
'sasl' => [
|
||||||
|
env('MEMCACHED_USERNAME'),
|
||||||
|
env('MEMCACHED_PASSWORD'),
|
||||||
|
],
|
||||||
|
'options' => [
|
||||||
|
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||||
|
],
|
||||||
|
'servers' => [
|
||||||
|
[
|
||||||
|
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('MEMCACHED_PORT', 11211),
|
||||||
|
'weight' => 100,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'driver' => 'redis',
|
||||||
|
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||||
|
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'dynamodb' => [
|
||||||
|
'driver' => 'dynamodb',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||||
|
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'octane' => [
|
||||||
|
'driver' => 'octane',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'driver' => 'failover',
|
||||||
|
'stores' => [
|
||||||
|
'database',
|
||||||
|
'array',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Key Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||||
|
| stores, there might be other applications using the same cache. For
|
||||||
|
| that reason, you may prefix every cache key to avoid collisions.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Serializable Classes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the classes that can be unserialized from cache
|
||||||
|
| storage. By default, no PHP classes will be unserialized from your
|
||||||
|
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'serializable_classes' => false,
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Pdo\Mysql;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Database Connection Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which of the database connections below you wish
|
||||||
|
| to use as your default connection for database operations. This is
|
||||||
|
| the connection which will be utilized unless another connection
|
||||||
|
| is explicitly specified when you execute a query / statement.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Database Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below are all of the database connections defined for your application.
|
||||||
|
| An example configuration is provided for each database system which
|
||||||
|
| is supported by Laravel. You're free to add / remove connections.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'sqlite' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||||
|
'prefix' => '',
|
||||||
|
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||||
|
'busy_timeout' => null,
|
||||||
|
'journal_mode' => null,
|
||||||
|
'synchronous' => null,
|
||||||
|
'transaction_mode' => 'DEFERRED',
|
||||||
|
],
|
||||||
|
|
||||||
|
'mysql' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'mariadb' => [
|
||||||
|
'driver' => 'mariadb',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'pgsql' => [
|
||||||
|
'driver' => 'pgsql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '5432'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'search_path' => 'public',
|
||||||
|
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'sqlsrv' => [
|
||||||
|
'driver' => 'sqlsrv',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', 'localhost'),
|
||||||
|
'port' => env('DB_PORT', '1433'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||||
|
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Migration Repository Table
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This table keeps track of all the migrations that have already run for
|
||||||
|
| your application. Using this information, we can determine which of
|
||||||
|
| the migrations on disk haven't actually been run on the database.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'migrations' => [
|
||||||
|
'table' => 'migrations',
|
||||||
|
'update_date_on_publish' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Redis Databases
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Redis is an open source, fast, and advanced key-value store that also
|
||||||
|
| provides a richer body of commands than a typical key-value system
|
||||||
|
| such as Memcached. You may define your connection settings here.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
|
||||||
|
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||||
|
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||||
|
'persistent' => env('REDIS_PERSISTENT', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
'default' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_DB', '0'),
|
||||||
|
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||||
|
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||||
|
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||||
|
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||||
|
],
|
||||||
|
|
||||||
|
'cache' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_CACHE_DB', '1'),
|
||||||
|
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||||
|
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||||
|
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||||
|
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Filesystem Disk
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the default filesystem disk that should be used
|
||||||
|
| by the framework. The "local" disk, as well as a variety of cloud
|
||||||
|
| based disks are available to your application for file storage.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Filesystem Disks
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below you may configure as many filesystem disks as necessary, and you
|
||||||
|
| may even configure multiple disks for the same driver. Examples for
|
||||||
|
| most supported storage drivers are configured here for reference.
|
||||||
|
|
|
||||||
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'disks' => [
|
||||||
|
|
||||||
|
'local' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/private'),
|
||||||
|
'serve' => true,
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'public' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/public'),
|
||||||
|
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
's3' => [
|
||||||
|
'driver' => 's3',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION'),
|
||||||
|
'bucket' => env('AWS_BUCKET'),
|
||||||
|
'url' => env('AWS_URL'),
|
||||||
|
'endpoint' => env('AWS_ENDPOINT'),
|
||||||
|
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Symbolic Links
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the symbolic links that will be created when the
|
||||||
|
| `storage:link` Artisan command is executed. The array keys should be
|
||||||
|
| the locations of the links and the values should be their targets.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'links' => [
|
||||||
|
public_path('storage') => storage_path('app/public'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Monolog\Handler\NullHandler;
|
||||||
|
use Monolog\Handler\StreamHandler;
|
||||||
|
use Monolog\Handler\SyslogUdpHandler;
|
||||||
|
use Monolog\Processor\PsrLogMessageProcessor;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Log Channel
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option defines the default log channel that is utilized to write
|
||||||
|
| messages to your logs. The value provided here should match one of
|
||||||
|
| the channels present in the list of "channels" configured below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('LOG_CHANNEL', 'stack'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Deprecations Log Channel
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the log channel that should be used to log warnings
|
||||||
|
| regarding deprecated PHP and library features. This allows you to get
|
||||||
|
| your application ready for upcoming major versions of dependencies.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'deprecations' => [
|
||||||
|
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||||
|
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Log Channels
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the log channels for your application. Laravel
|
||||||
|
| utilizes the Monolog PHP logging library, which includes a variety
|
||||||
|
| of powerful log handlers and formatters that you're free to use.
|
||||||
|
|
|
||||||
|
| Available drivers: "single", "daily", "slack", "syslog",
|
||||||
|
| "errorlog", "monolog", "custom", "stack"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'channels' => [
|
||||||
|
|
||||||
|
'stack' => [
|
||||||
|
'driver' => 'stack',
|
||||||
|
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||||
|
'ignore_exceptions' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'single' => [
|
||||||
|
'driver' => 'single',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'daily' => [
|
||||||
|
'driver' => 'daily',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'max_files' => env('LOG_DAILY_DAYS', 14),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'monthly' => [
|
||||||
|
'driver' => 'monthly',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'max_files' => 3,
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'slack' => [
|
||||||
|
'driver' => 'slack',
|
||||||
|
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||||
|
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
|
||||||
|
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||||
|
'level' => env('LOG_LEVEL', 'critical'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'papertrail' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||||
|
'handler_with' => [
|
||||||
|
'host' => env('PAPERTRAIL_URL'),
|
||||||
|
'port' => env('PAPERTRAIL_PORT'),
|
||||||
|
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||||
|
],
|
||||||
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'stderr' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'handler' => StreamHandler::class,
|
||||||
|
'handler_with' => [
|
||||||
|
'stream' => 'php://stderr',
|
||||||
|
],
|
||||||
|
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||||
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'syslog' => [
|
||||||
|
'driver' => 'syslog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'errorlog' => [
|
||||||
|
'driver' => 'errorlog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'null' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'handler' => NullHandler::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'emergency' => [
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Mailer
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default mailer that is used to send all email
|
||||||
|
| messages unless another mailer is explicitly specified when sending
|
||||||
|
| the message. All additional mailers can be configured within the
|
||||||
|
| "mailers" array. Examples of each type of mailer are provided.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('MAIL_MAILER', 'log'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Mailer Configurations
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure all of the mailers used by your application plus
|
||||||
|
| their respective settings. Several examples have been configured for
|
||||||
|
| you and you are free to add your own as your application requires.
|
||||||
|
|
|
||||||
|
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||||
|
| when delivering an email. You may specify which one you're using for
|
||||||
|
| your mailers below. You may also add additional mailers if needed.
|
||||||
|
|
|
||||||
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||||
|
| "postmark", "resend", "log", "array",
|
||||||
|
| "failover", "roundrobin"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'mailers' => [
|
||||||
|
|
||||||
|
'smtp' => [
|
||||||
|
'transport' => 'smtp',
|
||||||
|
'scheme' => env('MAIL_SCHEME'),
|
||||||
|
'url' => env('MAIL_URL'),
|
||||||
|
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('MAIL_PORT', 2525),
|
||||||
|
'username' => env('MAIL_USERNAME'),
|
||||||
|
'password' => env('MAIL_PASSWORD'),
|
||||||
|
'timeout' => null,
|
||||||
|
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||||
|
],
|
||||||
|
|
||||||
|
'ses' => [
|
||||||
|
'transport' => 'ses',
|
||||||
|
],
|
||||||
|
|
||||||
|
'postmark' => [
|
||||||
|
'transport' => 'postmark',
|
||||||
|
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||||
|
// 'client' => [
|
||||||
|
// 'timeout' => 5,
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
'resend' => [
|
||||||
|
'transport' => 'resend',
|
||||||
|
],
|
||||||
|
|
||||||
|
'sendmail' => [
|
||||||
|
'transport' => 'sendmail',
|
||||||
|
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'log' => [
|
||||||
|
'transport' => 'log',
|
||||||
|
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'transport' => 'array',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'transport' => 'failover',
|
||||||
|
'mailers' => [
|
||||||
|
'smtp',
|
||||||
|
'log',
|
||||||
|
],
|
||||||
|
'retry_after' => 60,
|
||||||
|
],
|
||||||
|
|
||||||
|
'roundrobin' => [
|
||||||
|
'transport' => 'roundrobin',
|
||||||
|
'mailers' => [
|
||||||
|
'ses',
|
||||||
|
'postmark',
|
||||||
|
],
|
||||||
|
'retry_after' => 60,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Global "From" Address
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| You may wish for all emails sent by your application to be sent from
|
||||||
|
| the same address. Here you may specify a name and address that is
|
||||||
|
| used globally for all emails that are sent by your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'from' => [
|
||||||
|
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||||
|
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Queue Connection Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Laravel's queue supports a variety of backends via a single, unified
|
||||||
|
| API, giving you convenient access to each backend using identical
|
||||||
|
| syntax for each. The default queue connection is defined below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Queue Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the connection options for every queue backend
|
||||||
|
| used by your application. An example configuration is provided for
|
||||||
|
| each backend supported by Laravel. You're also free to add more.
|
||||||
|
|
|
||||||
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||||
|
| "deferred", "background", "failover", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'sync' => [
|
||||||
|
'driver' => 'sync',
|
||||||
|
],
|
||||||
|
|
||||||
|
'database' => [
|
||||||
|
'driver' => 'database',
|
||||||
|
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||||
|
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||||
|
'queue' => env('DB_QUEUE', 'default'),
|
||||||
|
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'beanstalkd' => [
|
||||||
|
'driver' => 'beanstalkd',
|
||||||
|
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||||
|
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||||
|
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||||
|
'block_for' => 0,
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'sqs' => [
|
||||||
|
'driver' => 'sqs',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||||
|
'queue' => env('SQS_QUEUE', 'default'),
|
||||||
|
'suffix' => env('SQS_SUFFIX'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'driver' => 'redis',
|
||||||
|
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||||
|
'queue' => env('REDIS_QUEUE', 'default'),
|
||||||
|
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||||
|
'block_for' => null,
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'deferred' => [
|
||||||
|
'driver' => 'deferred',
|
||||||
|
],
|
||||||
|
|
||||||
|
'background' => [
|
||||||
|
'driver' => 'background',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'driver' => 'failover',
|
||||||
|
'connections' => [
|
||||||
|
'database',
|
||||||
|
'deferred',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Job Batching
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following options configure the database and table that store job
|
||||||
|
| batching information. These options can be updated to any database
|
||||||
|
| connection and table which has been defined by your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'batching' => [
|
||||||
|
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
'table' => 'job_batches',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Failed Queue Jobs
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These options configure the behavior of failed queue job logging so you
|
||||||
|
| can control how and where failed jobs are stored. Laravel ships with
|
||||||
|
| support for storing failed jobs in a simple file or in a database.
|
||||||
|
|
|
||||||
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'failed' => [
|
||||||
|
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||||
|
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
'table' => 'failed_jobs',
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Third Party Services
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This file is for storing the credentials for third party services such
|
||||||
|
| as Resend, Postmark, AWS, and more. This file provides the de facto
|
||||||
|
| location for this type of information, allowing packages to have
|
||||||
|
| a conventional file to locate the various service credentials.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'postmark' => [
|
||||||
|
'key' => env('POSTMARK_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'resend' => [
|
||||||
|
'key' => env('RESEND_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'ses' => [
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'slack' => [
|
||||||
|
'notifications' => [
|
||||||
|
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||||
|
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Session Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option determines the default session driver that is utilized for
|
||||||
|
| incoming requests. Laravel supports a variety of storage options to
|
||||||
|
| persist session data. Database storage is a great default choice.
|
||||||
|
|
|
||||||
|
| Supported: "file", "cookie", "database", "memcached",
|
||||||
|
| "redis", "dynamodb", "array"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'driver' => env('SESSION_DRIVER', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Lifetime
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the number of minutes that you wish the session
|
||||||
|
| to be allowed to remain idle before it expires. If you want them
|
||||||
|
| to expire immediately when the browser is closed then you may
|
||||||
|
| indicate that via the expire_on_close configuration option.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||||
|
|
||||||
|
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Encryption
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option allows you to easily specify that all of your session data
|
||||||
|
| should be encrypted before it's stored. All encryption is performed
|
||||||
|
| automatically by Laravel and you may use the session like normal.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session File Location
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When utilizing the "file" session driver, the session files are placed
|
||||||
|
| on disk. The default storage location is defined here; however, you
|
||||||
|
| are free to provide another location where they should be stored.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'files' => storage_path('framework/sessions'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Database Connection
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using the "database" or "redis" session drivers, you may specify a
|
||||||
|
| connection that should be used to manage these sessions. This should
|
||||||
|
| correspond to a connection in your database configuration options.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connection' => env('SESSION_CONNECTION'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Database Table
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using the "database" session driver, you may specify the table to
|
||||||
|
| be used to store sessions. Of course, a sensible default is defined
|
||||||
|
| for you; however, you're welcome to change this to another table.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'table' => env('SESSION_TABLE', 'sessions'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cache Store
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using one of the framework's cache driven session backends, you may
|
||||||
|
| define the cache store which should be used to store the session data
|
||||||
|
| between requests. This must match one of your defined cache stores.
|
||||||
|
|
|
||||||
|
| Affects: "dynamodb", "memcached", "redis"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'store' => env('SESSION_STORE'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Sweeping Lottery
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Some session drivers must manually sweep their storage location to get
|
||||||
|
| rid of old sessions from storage. Here are the chances that it will
|
||||||
|
| happen on a given request. By default, the odds are 2 out of 100.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lottery' => [2, 100],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may change the name of the session cookie that is created by
|
||||||
|
| the framework. Typically, you should not need to change this value
|
||||||
|
| since doing so does not grant a meaningful security improvement.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cookie' => env(
|
||||||
|
'SESSION_COOKIE',
|
||||||
|
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||||
|
),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The session cookie path determines the path for which the cookie will
|
||||||
|
| be regarded as available. Typically, this will be the root path of
|
||||||
|
| your application, but you're free to change this when necessary.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'path' => env('SESSION_PATH', '/'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Domain
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the domain and subdomains the session cookie is
|
||||||
|
| available to. By default, the cookie will be available to the root
|
||||||
|
| domain without subdomains. Typically, this shouldn't be changed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'domain' => env('SESSION_DOMAIN'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| HTTPS Only Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| By setting this option to true, session cookies will only be sent back
|
||||||
|
| to the server if the browser has a HTTPS connection. This will keep
|
||||||
|
| the cookie from being sent to you when it can't be done securely.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| HTTP Access Only
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Setting this value to true will prevent JavaScript from accessing the
|
||||||
|
| value of the cookie and the cookie will only be accessible through
|
||||||
|
| the HTTP protocol. It's unlikely you should disable this option.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Same-Site Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option determines how your cookies behave when cross-site requests
|
||||||
|
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||||
|
| will set this value to "lax" to permit secure cross-site requests.
|
||||||
|
|
|
||||||
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||||
|
|
|
||||||
|
| Supported: "lax", "strict", "none", null
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Partitioned Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Setting this value to true will tie the cookie to the top-level site for
|
||||||
|
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||||
|
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Serialization
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value controls the serialization strategy for session data, which
|
||||||
|
| is JSON by default. Setting this to "php" allows the storage of PHP
|
||||||
|
| objects in the session but can make an application vulnerable to
|
||||||
|
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
||||||
|
|
|
||||||
|
| Supported: "json", "php"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'serialization' => 'json',
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
*.sqlite*
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<User>
|
||||||
|
*/
|
||||||
|
class UserFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The current password being used by the factory.
|
||||||
|
*/
|
||||||
|
protected static ?string $password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => fake()->name(),
|
||||||
|
'email' => fake()->unique()->safeEmail(),
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'password' => static::$password ??= Hash::make('password'),
|
||||||
|
'remember_token' => Str::random(10),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate that the model's email address should be unverified.
|
||||||
|
*/
|
||||||
|
public function unverified(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'email_verified_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('users', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('email')->unique();
|
||||||
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
|
$table->string('password');
|
||||||
|
$table->rememberToken();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||||
|
$table->string('email')->primary();
|
||||||
|
$table->string('token');
|
||||||
|
$table->timestamp('created_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('sessions', function (Blueprint $table) {
|
||||||
|
$table->string('id')->primary();
|
||||||
|
$table->foreignId('user_id')->nullable()->index();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->text('user_agent')->nullable();
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->integer('last_activity')->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('users');
|
||||||
|
Schema::dropIfExists('password_reset_tokens');
|
||||||
|
Schema::dropIfExists('sessions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('cache', function (Blueprint $table) {
|
||||||
|
$table->string('key')->primary();
|
||||||
|
$table->mediumText('value');
|
||||||
|
$table->bigInteger('expiration')->index();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('cache_locks', function (Blueprint $table) {
|
||||||
|
$table->string('key')->primary();
|
||||||
|
$table->string('owner');
|
||||||
|
$table->bigInteger('expiration')->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('cache');
|
||||||
|
Schema::dropIfExists('cache_locks');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('jobs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('queue')->index();
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->unsignedSmallInteger('attempts');
|
||||||
|
$table->unsignedInteger('reserved_at')->nullable();
|
||||||
|
$table->unsignedInteger('available_at');
|
||||||
|
$table->unsignedInteger('created_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('job_batches', function (Blueprint $table) {
|
||||||
|
$table->string('id')->primary();
|
||||||
|
$table->string('name');
|
||||||
|
$table->integer('total_jobs');
|
||||||
|
$table->integer('pending_jobs');
|
||||||
|
$table->integer('failed_jobs');
|
||||||
|
$table->longText('failed_job_ids');
|
||||||
|
$table->mediumText('options')->nullable();
|
||||||
|
$table->integer('cancelled_at')->nullable();
|
||||||
|
$table->integer('created_at');
|
||||||
|
$table->integer('finished_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('uuid')->unique();
|
||||||
|
$table->string('connection');
|
||||||
|
$table->string('queue');
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->longText('exception');
|
||||||
|
$table->timestamp('failed_at')->useCurrent();
|
||||||
|
|
||||||
|
$table->index(['connection', 'queue', 'failed_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('jobs');
|
||||||
|
Schema::dropIfExists('job_batches');
|
||||||
|
Schema::dropIfExists('failed_jobs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('recordings', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('title');
|
||||||
|
$table->string('original_filename');
|
||||||
|
$table->string('file_path');
|
||||||
|
$table->unsignedInteger('duration_seconds')->nullable();
|
||||||
|
$table->timestamp('recorded_at')->nullable();
|
||||||
|
$table->string('artist')->nullable();
|
||||||
|
$table->string('album')->nullable();
|
||||||
|
$table->unsignedBigInteger('file_size_bytes')->default(0);
|
||||||
|
$table->longText('transcript')->nullable();
|
||||||
|
$table->string('transcription_status')->default('pending');
|
||||||
|
$table->string('transcription_driver')->nullable();
|
||||||
|
$table->string('ollama_url')->nullable();
|
||||||
|
$table->timestamp('transcribed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('recordings');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class DatabaseSeeder extends Seeder
|
||||||
|
{
|
||||||
|
use WithoutModelEvents;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed the application's database.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// User::factory(10)->create();
|
||||||
|
|
||||||
|
User::factory()->create([
|
||||||
|
'name' => 'Test User',
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2336
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://www.schemastore.org/package.json",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"dev": "vite"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"concurrently": "^10.0.3",
|
||||||
|
"laravel-vite-plugin": "^3.1",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@laravel/multiplex": "^0.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Unit">
|
||||||
|
<directory>tests/Unit</directory>
|
||||||
|
</testsuite>
|
||||||
|
<testsuite name="Feature">
|
||||||
|
<directory>tests/Feature</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory>app</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
<php>
|
||||||
|
<env name="APP_ENV" value="testing"/>
|
||||||
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
|
<env name="CACHE_STORE" value="array"/>
|
||||||
|
<env name="DB_CONNECTION" value="sqlite"/>
|
||||||
|
<env name="DB_DATABASE" value=":memory:"/>
|
||||||
|
<env name="DB_URL" value=""/>
|
||||||
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
|
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||||
|
</php>
|
||||||
|
</phpunit>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
<IfModule mod_negotiation.c>
|
||||||
|
Options -MultiViews -Indexes
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Handle Authorization Header
|
||||||
|
RewriteCond %{HTTP:Authorization} .
|
||||||
|
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||||
|
|
||||||
|
# Handle X-XSRF-Token Header
|
||||||
|
RewriteCond %{HTTP:x-xsrf-token} .
|
||||||
|
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
|
||||||
|
|
||||||
|
# Redirect Trailing Slashes If Not A Folder...
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{REQUEST_URI} (.+)/$
|
||||||
|
RewriteRule ^ %1 [L,R=301]
|
||||||
|
|
||||||
|
# Send Requests To Front Controller...
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteRule ^ index.php [L]
|
||||||
|
</IfModule>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
|
// Determine if the application is in maintenance mode...
|
||||||
|
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||||
|
require $maintenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the Composer autoloader...
|
||||||
|
require __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
// Bootstrap Laravel and handle the request...
|
||||||
|
/** @var Application $app */
|
||||||
|
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||||
|
|
||||||
|
$app->handleRequest(Request::capture());
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
|
@source '../../storage/framework/views/*.php';
|
||||||
|
@source '../views';
|
||||||
|
@source '../js';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||||
|
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
//
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>@yield('title', 'Recordings') — {{ config('app.name', 'AndyTranscribe') }}</title>
|
||||||
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased">
|
||||||
|
<header class="border-b border-stone-200 bg-white">
|
||||||
|
<div class="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
|
||||||
|
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800">
|
||||||
|
AndyTranscribe
|
||||||
|
</a>
|
||||||
|
<nav class="flex items-center gap-3 text-sm">
|
||||||
|
<a href="{{ route('recordings.index') }}" class="text-stone-600 hover:text-stone-900">Recordings</a>
|
||||||
|
<a href="{{ route('recordings.create') }}" class="rounded bg-teal-700 px-3 py-1.5 font-medium text-white hover:bg-teal-800">
|
||||||
|
Upload
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="mx-auto max-w-5xl px-4 py-8 sm:px-6">
|
||||||
|
@if (session('success'))
|
||||||
|
<div class="mb-6 rounded border border-teal-200 bg-teal-50 px-4 py-3 text-sm text-teal-900">
|
||||||
|
{{ session('success') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (session('error'))
|
||||||
|
<div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">
|
||||||
|
{{ session('error') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($errors->any())
|
||||||
|
<div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">
|
||||||
|
<ul class="list-disc space-y-1 pl-5">
|
||||||
|
@foreach ($errors->all() as $error)
|
||||||
|
<li>{{ $error }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@yield('content')
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Upload recording')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight">Upload recording</h1>
|
||||||
|
<p class="mt-1 text-sm text-stone-600">Upload an MP3 from your pocket recorder. ID3 metadata is extracted automatically.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="{{ route('recordings.store') }}"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
class="max-w-xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm"
|
||||||
|
>
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="audio" class="block text-sm font-medium text-stone-700">MP3 file</label>
|
||||||
|
<input
|
||||||
|
id="audio"
|
||||||
|
type="file"
|
||||||
|
name="audio"
|
||||||
|
accept=".mp3,audio/mpeg"
|
||||||
|
required
|
||||||
|
class="mt-2 block w-full text-sm text-stone-600 file:mr-4 file:rounded file:border-0 file:bg-teal-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-teal-800 hover:file:bg-teal-100"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="title" class="block text-sm font-medium text-stone-700">Title (optional)</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
value="{{ old('title') }}"
|
||||||
|
placeholder="Leave blank to use ID3 title or filename"
|
||||||
|
class="mt-2 w-full rounded border border-stone-300 px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button type="submit" class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800">
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 hover:underline">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Recordings')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight">Recordings</h1>
|
||||||
|
<p class="mt-1 text-sm text-stone-600">Manage pocket-recorder MP3s and transcripts.</p>
|
||||||
|
</div>
|
||||||
|
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
value="{{ $search ?? '' }}"
|
||||||
|
placeholder="Search title, artist, transcript…"
|
||||||
|
class="w-full min-w-[16rem] rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
|
||||||
|
>
|
||||||
|
<button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($recordings->isEmpty())
|
||||||
|
<div class="rounded border border-dashed border-stone-300 bg-white px-6 py-16 text-center">
|
||||||
|
<p class="text-stone-600">No recordings yet.</p>
|
||||||
|
<a href="{{ route('recordings.create') }}" class="mt-4 inline-block text-sm font-medium text-teal-700 hover:underline">
|
||||||
|
Upload your first MP3
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="overflow-hidden rounded border border-stone-200 bg-white shadow-sm">
|
||||||
|
<table class="min-w-full divide-y divide-stone-200 text-sm">
|
||||||
|
<thead class="bg-stone-50 text-left text-xs font-medium uppercase tracking-wide text-stone-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3">Title</th>
|
||||||
|
<th class="px-4 py-3">Duration</th>
|
||||||
|
<th class="px-4 py-3">Status</th>
|
||||||
|
<th class="px-4 py-3">Engine</th>
|
||||||
|
<th class="px-4 py-3">Uploaded</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-stone-100">
|
||||||
|
@foreach ($recordings as $recording)
|
||||||
|
<tr class="hover:bg-stone-50">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<a href="{{ route('recordings.show', $recording) }}" class="font-medium text-teal-800 hover:underline">
|
||||||
|
{{ $recording->title }}
|
||||||
|
</a>
|
||||||
|
@if ($recording->artist)
|
||||||
|
<div class="text-xs text-stone-500">{{ $recording->artist }}</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-stone-600">{{ $recording->duration_formatted }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
@include('recordings.partials.status-badge', ['status' => $recording->transcription_status])
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-stone-600">
|
||||||
|
@if ($recording->transcription_driver)
|
||||||
|
<span class="uppercase tracking-wide text-xs">{{ $recording->transcription_driver }}</span>
|
||||||
|
@else
|
||||||
|
—
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-stone-600">{{ $recording->created_at?->format('Y-m-d H:i') }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
{{ $recordings->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@php
|
||||||
|
$classes = match ($status) {
|
||||||
|
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20',
|
||||||
|
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20',
|
||||||
|
'failed' => 'bg-red-50 text-red-800 ring-red-600/20',
|
||||||
|
default => 'bg-stone-100 text-stone-700 ring-stone-500/20',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset {{ $classes }}">
|
||||||
|
{{ $status }}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', $recording->title)
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-500 hover:text-stone-800">← Recordings</a>
|
||||||
|
<h1 class="mt-2 text-2xl font-semibold tracking-tight">{{ $recording->title }}</h1>
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-stone-600">
|
||||||
|
@include('recordings.partials.status-badge', ['status' => $recording->transcription_status])
|
||||||
|
@if ($recording->transcription_driver)
|
||||||
|
<span class="text-xs uppercase tracking-wide text-stone-500">{{ $recording->transcription_driver }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ route('recordings.destroy', $recording) }}" onsubmit="return confirm('Delete this recording and its file?')">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="rounded border border-red-200 bg-white px-3 py-1.5 text-sm text-red-700 hover:bg-red-50">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
|
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Metadata</h2>
|
||||||
|
<dl class="mt-4 space-y-3 text-sm">
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<dt class="text-stone-500">Original file</dt>
|
||||||
|
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<dt class="text-stone-500">Duration</dt>
|
||||||
|
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<dt class="text-stone-500">Artist</dt>
|
||||||
|
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<dt class="text-stone-500">Album</dt>
|
||||||
|
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<dt class="text-stone-500">Recorded</dt>
|
||||||
|
<dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<dt class="text-stone-500">Size</dt>
|
||||||
|
<dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<dt class="text-stone-500">Uploaded</dt>
|
||||||
|
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
|
||||||
|
</div>
|
||||||
|
@if ($recording->ollama_url)
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<dt class="text-stone-500">Ollama host</dt>
|
||||||
|
<dd class="max-w-[60%] break-all text-right font-medium">{{ $recording->ollama_url }}</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
x-data="{ driver: '{{ old('driver', $recording->transcription_driver ?: 'cloud') }}' }"
|
||||||
|
class="rounded border border-stone-200 bg-white p-6 shadow-sm"
|
||||||
|
>
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcribe</h2>
|
||||||
|
<p class="mt-2 text-sm text-stone-600">Choose how to convert this MP3 to text.</p>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4 space-y-4">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
|
||||||
|
<input type="radio" name="driver" value="cloud" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm font-medium">Cloud (OpenAI Whisper)</span>
|
||||||
|
<span class="block text-xs text-stone-500">Fast; audio is sent to OpenAI.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
|
||||||
|
<input type="radio" name="driver" value="local" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm font-medium">Local — confidential (faster-whisper)</span>
|
||||||
|
<span class="block text-xs text-stone-500">Requires faster-whisper-server on this machine (Docker).</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
|
||||||
|
<input type="radio" name="driver" value="ollama" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm font-medium">Ollama host</span>
|
||||||
|
<span class="block text-xs text-stone-500">
|
||||||
|
Remote host URL. Must expose OpenAI-compatible <code class="text-[11px]">/v1/audio/transcriptions</code>
|
||||||
|
(e.g. Whisper beside Ollama).
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div x-show="driver === 'ollama'" x-cloak class="space-y-2">
|
||||||
|
<label for="ollama_url" class="block text-sm font-medium text-stone-700">Host URL</label>
|
||||||
|
<input
|
||||||
|
id="ollama_url"
|
||||||
|
type="url"
|
||||||
|
name="ollama_url"
|
||||||
|
value="{{ old('ollama_url', $recording->ollama_url) }}"
|
||||||
|
placeholder="http://192.168.1.50:8000"
|
||||||
|
class="w-full rounded border border-stone-300 px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
@disabled($recording->transcription_status === 'processing')
|
||||||
|
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ $recording->transcript ? 'Re-transcribe' : 'Start transcription' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="mt-6 rounded border border-stone-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcript</h2>
|
||||||
|
@if ($recording->transcript)
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="navigator.clipboard.writeText(@js($recording->transcript))"
|
||||||
|
class="text-sm text-teal-700 hover:underline"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($recording->transcription_status === 'processing')
|
||||||
|
<p class="mt-4 text-sm text-amber-700">Transcription in progress… Refresh shortly.</p>
|
||||||
|
@elseif ($recording->transcription_status === 'failed')
|
||||||
|
<p class="mt-4 text-sm text-red-700">Transcription failed. Check the logs and try again with another engine.</p>
|
||||||
|
@elseif ($recording->transcript)
|
||||||
|
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-stone-800">{{ $recording->transcript }}</p>
|
||||||
|
@if ($recording->transcribed_at)
|
||||||
|
<p class="mt-4 text-xs text-stone-500">Transcribed {{ $recording->transcribed_at->format('Y-m-d H:i') }}</p>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<p class="mt-4 text-sm text-stone-500">No transcript yet. Choose an engine above to start.</p>
|
||||||
|
@endif
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<style>[x-cloak]{display:none!important}</style>
|
||||||
|
@endsection
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Inspiring;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
|
||||||
|
Artisan::command('inspire', function () {
|
||||||
|
$this->comment(Inspiring::quote());
|
||||||
|
})->purpose('Display an inspiring quote');
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\RecordingController;
|
||||||
|
use App\Http\Controllers\TranscribeController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::redirect('/', '/recordings');
|
||||||
|
|
||||||
|
Route::resource('recordings', RecordingController::class)->except(['edit', 'update']);
|
||||||
|
Route::post('recordings/{recording}/transcribe', TranscribeController::class)->name('recordings.transcribe');
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
*
|
||||||
|
!private/
|
||||||
|
!public/
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
compiled.php
|
||||||
|
config.php
|
||||||
|
down
|
||||||
|
events.scanned.php
|
||||||
|
maintenance.php
|
||||||
|
routes.php
|
||||||
|
routes.scanned.php
|
||||||
|
schedule-*
|
||||||
|
services.json
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
*
|
||||||
|
!data/
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ExampleTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* A basic test example.
|
||||||
|
*/
|
||||||
|
public function test_the_application_returns_a_successful_response(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Jobs\TranscribeRecording;
|
||||||
|
use App\Models\Recording;
|
||||||
|
use App\Services\TranscriptionService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Laravel\Ai\Transcription;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class RecordingUploadTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_index_shows_recordings(): void
|
||||||
|
{
|
||||||
|
Recording::query()->create([
|
||||||
|
'title' => 'Pocket note',
|
||||||
|
'original_filename' => 'note.mp3',
|
||||||
|
'file_path' => 'recordings/note.mp3',
|
||||||
|
'file_size_bytes' => 1024,
|
||||||
|
'transcription_status' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('recordings.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Pocket note');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_can_upload_an_mp3(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
|
||||||
|
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
|
||||||
|
|
||||||
|
$response = $this->post(route('recordings.store'), [
|
||||||
|
'audio' => $file,
|
||||||
|
'title' => 'Team meeting',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recording = Recording::query()->first();
|
||||||
|
|
||||||
|
$this->assertNotNull($recording);
|
||||||
|
$response->assertRedirect(route('recordings.show', $recording));
|
||||||
|
$this->assertSame('Team meeting', $recording->title);
|
||||||
|
$this->assertSame('pending', $recording->transcription_status);
|
||||||
|
Storage::disk('local')->assertExists($recording->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_can_queue_cloud_transcription(): void
|
||||||
|
{
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'title' => 'Dictation',
|
||||||
|
'original_filename' => 'dictation.mp3',
|
||||||
|
'file_path' => 'recordings/dictation.mp3',
|
||||||
|
'file_size_bytes' => 2048,
|
||||||
|
'transcription_status' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->post(route('recordings.transcribe', $recording), [
|
||||||
|
'driver' => 'cloud',
|
||||||
|
])->assertRedirect();
|
||||||
|
|
||||||
|
Queue::assertPushed(TranscribeRecording::class);
|
||||||
|
|
||||||
|
$recording->refresh();
|
||||||
|
$this->assertSame('cloud', $recording->transcription_driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ollama_driver_requires_url(): void
|
||||||
|
{
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'title' => 'Secret call',
|
||||||
|
'original_filename' => 'call.mp3',
|
||||||
|
'file_path' => 'recordings/call.mp3',
|
||||||
|
'file_size_bytes' => 2048,
|
||||||
|
'transcription_status' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->from(route('recordings.show', $recording))
|
||||||
|
->post(route('recordings.transcribe', $recording), [
|
||||||
|
'driver' => 'ollama',
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors('ollama_url');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_transcription_job_stores_transcript(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
Storage::disk('local')->put('recordings/sample.mp3', 'fake-audio-bytes');
|
||||||
|
|
||||||
|
Transcription::fake(['Hello from the recorder.']);
|
||||||
|
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'title' => 'Sample',
|
||||||
|
'original_filename' => 'sample.mp3',
|
||||||
|
'file_path' => 'recordings/sample.mp3',
|
||||||
|
'file_size_bytes' => 12,
|
||||||
|
'transcription_status' => 'pending',
|
||||||
|
'transcription_driver' => 'cloud',
|
||||||
|
]);
|
||||||
|
|
||||||
|
(new TranscribeRecording($recording))->handle(app(TranscriptionService::class));
|
||||||
|
|
||||||
|
$recording->refresh();
|
||||||
|
$this->assertSame('done', $recording->transcription_status);
|
||||||
|
$this->assertSame('Hello from the recorder.', $recording->transcript);
|
||||||
|
$this->assertNotNull($recording->transcribed_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||||
|
|
||||||
|
abstract class TestCase extends BaseTestCase
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ExampleTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* A basic test example.
|
||||||
|
*/
|
||||||
|
public function test_that_true_is_true(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import laravel from 'laravel-vite-plugin';
|
||||||
|
import { bunny } from 'laravel-vite-plugin/fonts';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
laravel({
|
||||||
|
input: ['resources/css/app.css', 'resources/js/app.js'],
|
||||||
|
refresh: true,
|
||||||
|
fonts: [
|
||||||
|
bunny('Instrument Sans', {
|
||||||
|
weights: [400, 500, 600],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
watch: {
|
||||||
|
ignored: ['**/storage/framework/views/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user