Alle Artikel
Laravel4 min

Laravel REST API Tutorial: Offline-First in 30 Min

Moderne Web-Apps müssen auch ohne Internetverbindung funktionieren. In diesem Laravel REST API Tutorial zeige ich dir, wie du eine API mit Offline-First-Fähigkeiten baust – Schritt für Schritt, produktionsreif und in unter 30 Minuten.

Warum Offline-First bei REST APIs wichtig ist

Nutzer erwarten heute, dass Apps auch bei schlechter Verbindung funktionieren. Eine gut strukturierte Laravel REST API legt dafür die Grundlage. Du baust eine API, die:

  • Requests sauber strukturiert
  • Optimistische Updates ermöglicht
  • Konflikte elegant auflöst
  • Mit Service Workers zusammenarbeitet

Projektsetup

Starte mit einer frischen Laravel-Installation:

composer create-project laravel/laravel offline-api
cd offline-api
php artisan serve

Installiere zusätzliche Pakete für API-Entwicklung:

composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate

Datenmodell für Offline-Sync

Für Offline-First brauchst du Metadaten zum Synchronisieren. Erstelle eine Migration für eine Todo-App:

php artisan make:model Todo -m

In der Migration database/migrations/*_create_todos_table.php:

public function up()
{
    Schema::create('todos', function (Blueprint $table) {
        $table->id();
        $table->string('title');
        $table->boolean('completed')->default(false);
        $table->string('client_id')->unique()->nullable();
        $table->timestamp('synced_at')->nullable();
        $table->timestamp('updated_at_client')->nullable();
        $table->timestamps();
        $table->softDeletes();
    });
}

Die Felder client_id, synced_at und updated_at_client sind essentiell für Offline-Sync. Sie ermöglichen Konfliktauflösung.

API Routes strukturieren

In routes/api.php:

use App\Http\Controllers\TodoController;
use Illuminate\Support\Facades\Route;

Route::middleware('api')->group(function () {
    Route::get('/todos', [TodoController::class, 'index']);
    Route::post('/todos', [TodoController::class, 'store']);
    Route::put('/todos/{todo}', [TodoController::class, 'update']);
    Route::delete('/todos/{todo}', [TodoController::class, 'destroy']);
    Route::post('/todos/sync', [TodoController::class, 'sync']);
});

Der /sync-Endpoint ist das Herzstück für Offline-First.

Controller mit Sync-Logik

Erstelle den Controller:

php artisan make:controller TodoController

In app/Http/Controllers/TodoController.php:

<?php

namespace App\Http\Controllers;

use App\Models\Todo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class TodoController extends Controller
{
    public function index(Request $request)
    {
        $lastSync = $request->query('last_sync');
        
        $query = Todo::query();
        
        if ($lastSync) {
            $query->where('updated_at', '>', $lastSync)
                  ->orWhereNotNull('deleted_at');
        }
        
        return response()->json([
            'data' => $query->withTrashed()->get(),
            'server_time' => now()->toISOString()
        ]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'client_id' => 'required|string|unique:todos',
            'updated_at_client' => 'required|date'
        ]);

        $todo = Todo::create([
            'title' => $validated['title'],
            'client_id' => $validated['client_id'],
            'updated_at_client' => $validated['updated_at_client'],
            'synced_at' => now()
        ]);

        return response()->json($todo, 201);
    }

    public function update(Request $request, Todo $todo)
    {
        $validated = $request->validate([
            'title' => 'sometimes|string|max:255',
            'completed' => 'sometimes|boolean',
            'updated_at_client' => 'required|date'
        ]);

        // Konflikt-Check
        if ($todo->updated_at_client > $validated['updated_at_client']) {
            return response()->json([
                'error' => 'conflict',
                'server_version' => $todo
            ], 409);
        }

        $todo->update($validated);
        $todo->synced_at = now();
        $todo->save();

        return response()->json($todo);
    }

    public function sync(Request $request)
    {
        $validated = $request->validate([
            'changes' => 'required|array',
            'last_sync' => 'nullable|date'
        ]);

        $results = [
            'applied' => [],
            'conflicts' => [],
            'server_changes' => []
        ];

        DB::transaction(function () use ($validated, &$results) {
            foreach ($validated['changes'] as $change) {
                $existing = Todo::where('client_id', $change['client_id'])->first();
                
                if ($existing) {
                    if ($existing->updated_at_client > $change['updated_at_client']) {
                        $results['conflicts'][] = [
                            'client_data' => $change,
                            'server_data' => $existing
                        ];
                    } else {
                        $existing->update($change);
                        $results['applied'][] = $existing;
                    }
                } else {
                    $todo = Todo::create($change);
                    $results['applied'][] = $todo;
                }
            }

            if ($validated['last_sync']) {
                $results['server_changes'] = Todo::where('updated_at', '>', $validated['last_sync'])
                    ->withTrashed()
                    ->get();
            }
        });

        $results['server_time'] = now()->toISOString();

        return response()->json($results);
    }

    public function destroy(Todo $todo)
    {
        $todo->delete();
        return response()->json(null, 204);
    }
}

Das Todo-Model

In app/Models/Todo.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Todo extends Model
{
    use SoftDeletes;

    protected $fillable = [
        'title',
        'completed',
        'client_id',
        'updated_at_client',
        'synced_at'
    ];

    protected $casts = [
        'completed' => 'boolean',
        'updated_at_client' => 'datetime',
        'synced_at' => 'datetime'
    ];
}

Frontend-Integration (TypeScript Beispiel)

So nutzt du die API im Frontend:

interface Todo {
    id?: number;
    title: string;
    completed: boolean;
    client_id: string;
    updated_at_client: string;
}

class OfflineSync {
    private pendingChanges: Todo[] = [];
    private lastSync: string | null = null;

    async sync(): Promise<void> {
        if (!navigator.onLine) return;

        try {
            const response = await fetch('/api/todos/sync', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                    changes: this.pendingChanges,
                    last_sync: this.lastSync
                })
            });

            const result = await response.json();
            
            // Angewendete Changes aus Queue entfernen
            this.pendingChanges = this.pendingChanges.filter(
                change => !result.applied.find(
                    (applied: Todo) => applied.client_id === change.client_id
                )
            );

            // Konflikte behandeln
            result.conflicts.forEach((conflict: any) => {
                this.handleConflict(conflict);
            });

            // Server-Changes mergen
            this.mergeServerChanges(result.server_changes);

            this.lastSync = result.server_time;
        } catch (error) {
            console.error('Sync failed:', error);
        }
    }

    addChange(todo: Todo): void {
        this.pendingChanges.push({
            ...todo,
            client_id: todo.client_id || crypto.randomUUID(),
            updated_at_client: new Date().toISOString()
        });
    }

    private handleConflict(conflict: any): void {
        // Strategie: Server gewinnt (kann angepasst werden)
        console.warn('Conflict detected:', conflict);
    }

    private mergeServerChanges(changes: Todo[]): void {
        // IndexedDB oder lokalen State aktualisieren
        changes.forEach(change => {
            // Implementierung abhängig von deinem State Management
        });
    }
}

Performance-Optimierung

Für produktiven Einsatz dieser Laravel REST API:

1. Caching aktivieren:

public function index(Request $request)
{
    $lastSync = $request->query('last_sync');
    $cacheKey = "todos.{$lastSync}";
    
    return Cache::remember($cacheKey, 60, function () use ($lastSync) {
        // Query wie oben
    });
}

2. Pagination für große Datensätze:

return Todo::where('updated_at', '>', $lastSync)
    ->withTrashed()
    ->paginate(100);

3. Database-Indizes:

$table->index(['updated_at', 'client_id']);
$table->index('synced_at');

Testing der API

Teste deine API mit PHPUnit:

public function test_sync_applies_changes()
{
    $response = $this->postJson('/api/todos/sync', [
        'changes' => [
            [
                'title' => 'Test Todo',
                'client_id' => 'test-123',
                'updated_at_client' => now()->toISOString()
            ]
        ]
    ]);

    $response->assertStatus(200);
    $this->assertDatabaseHas('todos', ['client_id' => 'test-123']);
}

Fazit

Mit diesem Laravel REST API Tutorial hast du eine solide Basis für Offline-First-Anwendungen geschaffen. Die API handhabt Synchronisation, Konflikte und funktioniert auch bei instabilen Verbindungen.

Die wichtigsten Takeaways:

  • client_id für eindeutige Identifikation
  • Timestamps für Konfliktauflösung
  • Batch-Sync-Endpoint für Effizienz
  • Soft Deletes für vollständigen Sync

Du arbeitest an einem Laravel REST API-Projekt mit komplexen Sync-Anforderungen? Ich helfe dir gerne bei der Implementierung und Optimierung deiner API-Architektur.

Laravel REST API TutorialLaravelFreelancerWebentwicklungDüsseldorf