Files
michaelschiemer/docs/claude/crud-system.md
2025-11-24 21:28:25 +01:00

13 KiB

Einheitliches CRUD-System

Übersicht

Das einheitliche CRUD-System bietet eine zentrale Abstraktion für alle Admin-CRUD-Operationen im Framework. Es basiert auf Komposition statt Vererbung und folgt den Framework-Prinzipien.

Architektur

Komponenten

  • CrudService: Zentrale Service-Klasse für CRUD-Operationen
  • CrudConfig: Value Object für CRUD-Konfiguration
  • AdminPageRenderer: Rendering von Admin-Seiten (wird von CrudService verwendet)
  • AdminTableFactory: Erstellung von Tabellen (wird von CrudService verwendet)
  • AdminFormFactory: Erstellung von Formularen (wird von CrudService verwendet)

Design-Prinzipien

  • Komposition statt Vererbung: Keine Base-Controller-Klassen
  • Value Objects: CrudConfig als immutable Value Object
  • Dependency Injection: Alle Abhängigkeiten werden injiziert
  • Separation of Concerns: Controller enthalten nur Business-Logik, Rendering wird delegiert

Verwendung

Grundlegende CRUD-Implementierung

final readonly class MyResourceController
{
    private CrudConfig $config;

    public function __construct(
        private CrudService $crudService,
        private MyResourceRepository $repository
    ) {
        $this->config = CrudConfig::forResource(
            resource: 'my-resource',
            resourceName: 'My Resource',
            title: 'My Resources'
        )->withColumns([
            ['field' => 'name', 'label' => 'Name', 'sortable' => true, 'searchable' => true],
            ['field' => 'created_at', 'label' => 'Created', 'sortable' => true, 'formatter' => 'date'],
        ]);
    }

    #[Route('/admin/my-resource', Method::GET)]
    public function index(Request $request): ViewResult
    {
        $items = $this->repository->findAll();
        $itemData = array_map(fn($item) => $item->toArray(), $items);

        return $this->crudService->renderIndex(
            config: $this->config,
            items: $itemData,
            request: $request
        );
    }

    #[Route('/admin/my-resource/create', Method::GET)]
    public function create(): ViewResult
    {
        return $this->crudService->renderCreate(
            config: $this->config,
            formFields: [
                'name' => [
                    'type' => 'text',
                    'label' => 'Name',
                    'required' => true,
                ],
            ]
        );
    }

    #[Route('/admin/my-resource', Method::POST)]
    public function store(Request $request): Redirect
    {
        try {
            $item = $this->repository->create($request->body->toArray());
            return $this->crudService->redirectAfterCreate(
                config: $this->config,
                request: $request,
                itemId: $item->id->toString()
            );
        } catch (\Throwable $e) {
            return $this->crudService->redirectWithError(
                'Failed to create: ' . $e->getMessage(),
                $request->body->toArray()
            );
        }
    }

    #[Route('/admin/my-resource/{id}/edit', Method::GET)]
    public function edit(string $id): ViewResult
    {
        $item = $this->repository->findById($id);
        return $this->crudService->renderEdit(
            config: $this->config,
            id: $id,
            formFields: [
                'name' => [
                    'type' => 'text',
                    'label' => 'Name',
                    'required' => true,
                ],
            ],
            itemData: $item->toArray()
        );
    }

    #[Route('/admin/my-resource/{id}', Method::PUT)]
    public function update(string $id, Request $request): Redirect
    {
        try {
            $this->repository->update($id, $request->body->toArray());
            return $this->crudService->redirectAfterUpdate(
                config: $this->config,
                request: $request,
                itemId: $id
            );
        } catch (\Throwable $e) {
            return $this->crudService->redirectWithError(
                'Failed to update: ' . $e->getMessage(),
                $request->body->toArray()
            );
        }
    }

    #[Route('/admin/my-resource/{id}', Method::DELETE)]
    public function destroy(string $id): Redirect
    {
        try {
            $this->repository->delete($id);
            return $this->crudService->redirectAfterDelete($this->config);
        } catch (\Throwable $e) {
            return $this->crudService->redirectWithError(
                'Failed to delete: ' . $e->getMessage()
            );
        }
    }
}

CrudConfig Konfiguration

Grundkonfiguration

$config = CrudConfig::forResource(
    resource: 'users',
    resourceName: 'User',
    title: 'Users'
);

Spalten definieren

$config = $config->withColumns([
    // Format 1: Mit 'field' Key
    ['field' => 'name', 'label' => 'Name', 'sortable' => true, 'searchable' => true],
    
    // Format 2: Key ist der Feldname
    'email' => ['label' => 'Email', 'sortable' => true, 'formatter' => 'email'],
    
    // Mit Formatter
    'created_at' => ['label' => 'Created', 'sortable' => true, 'formatter' => 'date'],
    
    // Mit CSS-Klasse
    'status' => ['label' => 'Status', 'formatter' => 'status', 'class' => 'text-center'],
]);

Berechtigungen konfigurieren

$config = $config->withPermissions(
    canCreate: true,
    canEdit: true,
    canView: true,
    canDelete: false  // Delete deaktiviert
);

Bulk-Actions

$config = $config->withBulkActions([
    [
        'label' => 'Delete',
        'action' => 'delete',
        'method' => 'DELETE',
        'style' => 'danger',
        'confirm' => 'Delete Selected Items',
    ],
    [
        'label' => 'Publish',
        'action' => 'publish',
        'method' => 'POST',
        'style' => 'primary',
    ],
]);

Filter-Optionen

$config = $config->withFilterOptions([
    'statuses' => ['draft', 'published', 'archived'],
    'categories' => $categories,
    'current_filters' => $activeFilters,
]);

Zusätzliche Actions

$config = $config->withAdditionalActions([
    [
        'url' => '/admin/resource/stats',
        'label' => 'Statistics',
        'icon' => 'chart-bar',
    ],
]);

Custom Table-Konfiguration

$config = $config->withTableConfig([
    'editUrlTemplate' => '/admin/custom-resource/{id}/edit',
    'viewUrlTemplate' => '/admin/custom-resource/{id}/view',
    'rowActions' => true,
]);

Erweiterte Verwendung

Custom Filter-Logik

Für komplexe Filter bleibt die Logik im Controller:

public function index(Request $request): ViewResult
{
    // Custom filter logic
    $filters = MyFilters::fromArray($request->query->toArray());
    $items = $this->repository->findWithFilters($filters);
    
    // Data enrichment
    $itemData = array_map(function ($item) {
        $data = $item->toArray();
        $data['custom_field'] = $this->enrichData($item);
        return $data;
    }, $items);
    
    // Use CrudService with filter options
    $configWithFilters = $this->config->withFilterOptions([
        'filter_options' => $this->getFilterOptions(),
        'current_filters' => $filters->toArray(),
    ]);
    
    return $this->crudService->renderIndex(
        config: $configWithFilters,
        items: $itemData,
        request: $request
    );
}

LiveComponents für spezielle Formulare

Für komplexe Formulare können LiveComponents verwendet werden:

public function create(): ViewResult
{
    // Use LiveComponent for complex form
    $component = $this->componentRegistry->resolve(...);
    return $this->pageRenderer->renderLiveForm(
        resource: 'my-resource',
        component: $component,
        title: 'Create Resource'
    );
}

public function store(Request $request): Redirect
{
    // After successful creation, use CrudService redirect
    return $this->crudService->redirectAfterCreate(
        config: $this->config,
        request: $request,
        itemId: $item->id->toString()
    );
}

Custom Show-View

public function show(string $id): ViewResult
{
    $item = $this->repository->findById($id);
    
    return $this->crudService->renderShow(
        config: $this->config,
        id: $id,
        fields: [
            ['label' => 'Name', 'value' => $item->name, 'type' => 'text'],
            ['label' => 'Status', 'value' => $item->status, 'type' => 'badge', 'color' => 'success'],
        ],
        metadata: [
            'created_at' => $item->createdAt->toIso8601(),
            'updated_at' => $item->updatedAt->toIso8601(),
        ],
        relatedItems: [
            'related_data' => $this->getRelatedData($item),
        ],
        actions: [
            ['type' => 'link', 'url' => "/admin/resource/{$id}/custom", 'label' => 'Custom Action'],
        ]
    );
}

Migration bestehender Controller

Schritt 1: Dependencies ersetzen

Vorher:

public function __construct(
    private AdminPageRenderer $pageRenderer,
    private AdminTableFactory $tableFactory,
    private AdminFormFactory $formFactory,
    private MyRepository $repository
) {}

Nachher:

public function __construct(
    private CrudService $crudService,
    private MyRepository $repository
) {
    $this->config = CrudConfig::forResource(...);
}

Schritt 2: Index-Methode migrieren

Vorher:

public function index(): ViewResult
{
    $tableConfig = AdminTableConfig::create(...);
    $table = $this->tableFactory->create($tableConfig, $data);
    return $this->pageRenderer->renderIndex(...);
}

Nachher:

public function index(Request $request): ViewResult
{
    $items = $this->repository->findAll();
    $itemData = array_map(fn($item) => $item->toArray(), $items);
    return $this->crudService->renderIndex(
        config: $this->config,
        items: $itemData,
        request: $request
    );
}

Schritt 3: Create/Edit migrieren

Vorher:

public function create(): ViewResult
{
    $formConfig = new AdminFormConfig(...);
    $form = $this->formFactory->create($formConfig);
    return $this->pageRenderer->renderForm(...);
}

Nachher:

public function create(): ViewResult
{
    return $this->crudService->renderCreate(
        config: $this->config,
        formFields: [...]
    );
}

Schritt 4: Redirects migrieren

Vorher:

return new Redirect('/admin/resource', status: 303, flashMessage: 'Success');

Nachher:

return $this->crudService->redirectAfterCreate(
    config: $this->config,
    request: $request,
    itemId: $item->id->toString()
);

Best Practices

1. CrudConfig im Constructor erstellen

Erstelle die CrudConfig einmal im Constructor, nicht in jeder Methode:

public function __construct(...)
{
    $this->config = CrudConfig::forResource(...)
        ->withColumns([...])
        ->withBulkActions([...]);
}

2. Data-Transformation

Konvertiere Entities zu Arrays für die Tabelle:

$itemData = array_map(
    fn($item) => $item->toArray(),
    $items
);

3. Error-Handling

Verwende immer redirectWithError() für Fehler:

try {
    // ...
} catch (\Throwable $e) {
    return $this->crudService->redirectWithError(
        'Failed: ' . $e->getMessage(),
        $request->body->toArray()  // Preserve input for form
    );
}

4. Custom-Logik bleibt im Controller

Komplexe Filter, Data-Enrichment und spezielle Features bleiben im Controller. CrudService übernimmt nur das Standard-Rendering.

5. LiveComponents für komplexe Formulare

Für sehr komplexe Formulare (z.B. mit Drag & Drop, Live-Updates) verwende LiveComponents statt Standard-Formulare.

Verfügbare Formatter

  • date: Datumsformatierung
  • status: Status-Badges
  • boolean: Boolean-Werte mit Badges
  • currency: Währungsformatierung
  • number: Zahlenformatierung
  • masked: Maskierte Werte
  • preview: Preview-Format (z.B. für Bilder)

URL-Templates

Standard-URLs werden automatisch generiert:

  • Create: /admin/{resource}/create
  • Edit: /admin/{resource}/edit/{id}
  • View: /admin/{resource}/view/{id}
  • Delete: /admin/{resource}/delete/{id}

Custom URLs können über withTableConfig() konfiguriert werden.

Beispiele

Siehe src/Framework/Admin/Examples/CampaignCrudController.php für ein vollständiges Beispiel.

Migration-Guide

  1. Einfache Controller (wie Asset Collections): Vollständige Migration möglich
  2. Mittlere Controller (wie Contents): Index und Redirects migrieren, LiveComponents bleiben
  3. Komplexe Controller (wie Assets): Hybrid-Ansatz - Standard-CRUD über Service, Custom-Features bleiben

Troubleshooting

Problem: Spalten werden nicht angezeigt

Lösung: Stelle sicher, dass die Spalten-Namen in withColumns() mit den Keys in den Daten übereinstimmen.

Problem: Formular-Felder werden nicht angezeigt

Lösung: Verwende das korrekte Format für formFields:

'field_name' => [
    'type' => 'text',
    'label' => 'Label',
    'required' => true,
]

Problem: Redirects funktionieren nicht

Lösung: Stelle sicher, dass Request $request als Parameter übergeben wird.

Weitere Informationen

  • Siehe src/Framework/Admin/Services/CrudService.php für die vollständige API
  • Siehe src/Framework/Admin/ValueObjects/CrudConfig.php für alle Konfigurationsoptionen
  • Siehe migrierte Controller für praktische Beispiele