Laravel-Entwicklungsmuster
Produktionsreife Laravel-Architekturmuster für skalierbare, wartbare Anwendungen.
Wann verwenden
- Erstellen von Laravel-Webanwendungen oder APIs
- Strukturieren von Controllern, Services und Domänenlogik
- Arbeiten mit Eloquent-Modellen und Beziehungen
- Entwerfen von APIs mit Ressourcen und Paginierung
- Hinzufügen von Warteschlangen, Events, Caching und Hintergrundjobs
Wie es funktioniert
- Strukturieren Sie die App um klare Grenzen (Controller -> Services/Aktionen -> Modelle).
- Verwenden Sie explizite Bindungen und scoped Bindungen, um das Routing vorhersehbar zu halten; erzwingen Sie dennoch Autorisierung für die Zugriffskontrolle.
- Bevorzugen Sie typisierte Modelle, Casts und Scopes, um die Domänenlogik konsistent zu halten.
- Verlagern Sie IO-intensive Arbeit in Warteschlangen und cachen Sie teure Abfragen.
- Zentralisieren Sie die Konfiguration in
config/*und halten Sie Umgebungen explizit.
Beispiele
Projektstruktur
Verwenden Sie ein konventionelles Laravel-Layout mit klaren Schichtgrenzen (HTTP, Services/Aktionen, Modelle).
Empfohlenes Layout
app/
├── Actions/ # Einzweck-Anwendungsfälle
├── Console/
├── Events/
├── Exceptions/
├── Http/
│ ├── Controllers/
│ ├── Middleware/
│ ├── Requests/ # Form-Request-Validierung
│ └── Resources/ # API-Ressourcen
├── Jobs/
├── Models/
├── Policies/
├── Providers/
├── Services/ # Koordinierende Domänendienste
└── Support/
config/
database/
├── factories/
├── migrations/
└── seeders/
resources/
├── views/
└── lang/
routes/
├── api.php
├── web.php
└── console.php
Controller -> Services -> Aktionen
Halten Sie Controller schlank. Verlagern Sie Orchestrierung in Services und Single-Purpose-Logik in Aktionen.
final class CreateOrderAction
{
public function __construct(private OrderRepository $orders) {}
public function handle(CreateOrderData $data): Order
{
return $this->orders->create($data);
}
}
final class OrdersController extends Controller
{
public function __construct(private CreateOrderAction $createOrder) {}
public function store(StoreOrderRequest $request): JsonResponse
{
$order = $this->createOrder->handle($request->toDto());
return response()->json([
'success' => true,
'data' => OrderResource::make($order),
'error' => null,
'meta' => null,
], 201);
}
}
Routing und Controller
Bevorzugen Sie Route-Model-Binding und Resource-Controller für Klarheit.
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('projects', ProjectController::class);
});
Route-Model-Binding (Scoped)
Verwenden Sie scoped Bindings, um Cross-Tenant-Zugriff zu verhindern.
Route::scopeBindings()->group(function () {
Route::get('/accounts/{account}/projects/{project}', [ProjectController::class, 'show']);
});
Verschachtelte Routen und Binding-Namen
- Halten Sie Präfixe und Pfade konsistent, um doppelte Verschachtelung zu vermeiden (z. B.
conversationvsconversations). - Verwenden Sie einen einzelnen Parameternamen, der mit dem gebundenen Modell übereinstimmt (z. B.
{conversation}fürConversation). - Bevorzugen Sie scoped Bindings bei Verschachtelung, um Eltern-Kind-Beziehungen zu erzwingen.
use App\Http\Controllers\Api\ConversationController;
use App\Http\Controllers\Api\MessageController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->prefix('conversations')->group(function () {
Route::post('/', [ConversationController::class, 'store'])->name('conversations.store');
Route::scopeBindings()->group(function () {
Route::get('/{conversation}', [ConversationController::class, 'show'])
->name('conversations.show');
Route::post('/{conversation}/messages', [MessageController::class, 'store'])
->name('conversation-messages.store');
Route::get('/{conversation}/messages/{message}', [MessageController::class, 'show'])
->name('conversation-messages.show');
});
});
Wenn ein Parameter zu einer anderen Modellklasse aufgelöst werden soll, definieren Sie eine explizite Bindung. Für benutzerdefinierte Bindungslogik verwenden Sie Route::bind() oder implementieren Sie resolveRouteBinding() im Modell.
use App\Models\AiConversation;
use Illuminate\Support\Facades\Route;
Route::model('conversation', AiConversation::class);
Service-Container-Bindungen
Binden Sie Schnittstellen an Implementierungen in einem Service-Provider für eine klare Abhängigkeitsverkabelung.
use App\Repositories\EloquentOrderRepository;
use App\Repositories\OrderRepository;
use Illuminate\Support\ServiceProvider;
final class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
}
}
Eloquent-Modell-Muster
Modellkonfiguration
final class Project extends Model
{
use HasFactory;
protected $fillable = ['name', 'owner_id', 'status'];
protected $casts = [
'status' => ProjectStatus::class,
'archived_at' => 'datetime',
];
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('archived_at');
}
}
Benutzerdefinierte Casts und Value Objects
Verwenden Sie Enums oder Value Objects für strenge Typisierung.
use Illuminate\Database\Eloquent\Casts\Attribute;
protected $casts = [
'status' => ProjectStatus::class,
];
protected function budgetCents(): Attribute
{
return Attribute::make(
get: fn (int $value) => Money::fromCents($value),
set: fn (Money $money) => $money->toCents(),
);
}
Eager Loading zur Vermeidung von N+1
$orders = Order::query()
->with(['customer', 'items.product'])
->latest()
->paginate(25);
Query-Objekte für komplexe Filter
final class ProjectQuery
{
public function __construct(private Builder $query) {}
public function ownedBy(int $userId): self
{
$query = clone $this->query;
return new self($query->where('owner_id', $userId));
}
public function active(): self
{
$query = clone $this->query;
return new self($query->whereNull('archived_at'));
}
public function builder(): Builder
{
return $this->query;
}
}
Globale Scopes und Soft Deletes
Verwenden Sie globale Scopes für Standardfilter und SoftDeletes für wiederherstellbare Datensätze.
Verwenden Sie entweder einen globalen Scope oder einen benannten Scope für denselben Filter, nicht beide, es sei denn, Sie beabsichtigen ein geschichtetes Verhalten.
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;
final class Project extends Model
{
use SoftDeletes;
protected static function booted(): void
{
static::addGlobalScope('active', function (Builder $builder): void {
$builder->whereNull('archived_at');
});
}
}
Query Scopes für wiederverwendbare Filter
use Illuminate\Database\Eloquent\Builder;
final class Project extends Model
{
public function scopeOwnedBy(Builder $query, int $userId): Builder
{
return $query->where('owner_id', $userId);
}
}
// Im Service, Repository etc.
$projects = Project::ownedBy($user->id)->get();
Transaktionen für mehrschrittige Aktualisierungen
use Illuminate\Support\Facades\DB;
DB::transaction(function (): void {
$order->update(['status' => 'paid']);
$order->items()->update(['paid_at' => now()]);
});
Migrationen
Namenskonvention
- Dateinamen verwenden Zeitstempel:
YYYY_MM_DD_HHMMSS_create_users_table.php - Migrationen verwenden anonyme Klassen (keine benannte Klasse); der Dateiname kommuniziert den Zweck
- Tabellennamen sind
snake_caseund standardmäßig plural
Beispiel-Migration
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table): void {
$table->id();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
$table->string('status', 32)->index();
$table->unsignedInteger('total_cents');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};
Form Requests und Validierung
Halten Sie die Validierung in Form Requests und transformieren Sie Eingaben zu DTOs.
use App\Models\Order;
final class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create', Order::class) ?? false;
}
public function rules(): array
{
return [
'customer_id' => ['required', 'integer', 'exists:customers,id'],
'items' => ['required', 'array', 'min:1'],
'items.*.sku' => ['required', 'string'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
];
}
public function toDto(): CreateOrderData
{
return new CreateOrderData(
customerId: (int) $this->validated('customer_id'),
items: $this->validated('items'),
);
}
}
API-Ressourcen
Halten Sie API-Antworten mit Ressourcen und Paginierung konsistent.
$projects = Project::query()->active()->paginate(25);
return response()->json([
'success' => true,
'data' => ProjectResource::collection($projects->items()),
'error' => null,
'meta' => [
'page' => $projects->currentPage(),
'per_page' => $projects->perPage(),
'total' => $projects->total(),
],
]);
Events, Jobs und Warteschlangen
- Emittieren Sie Domänen-Events für Nebeneffekte (E-Mails, Analysen)
- Verwenden Sie Warteschlangen-Jobs für langsame Arbeit (Berichte, Exporte, Webhooks)
- Bevorzugen Sie idempotente Handler mit Wiederholungen und Backoff
Caching
- Cachen Sie leseintensive Endpunkte und teure Abfragen
- Invalidieren Sie Caches bei Modell-Events (created/updated/deleted)
- Verwenden Sie Tags beim Cachen verwandter Daten für einfache Invalidierung
Konfiguration und Umgebungen
- Halten Sie Geheimnisse in
.envund Konfiguration inconfig/*.php - Verwenden Sie umgebungsspezifische Konfigurationsüberschreibungen und
config:cachein der Produktion