Chandan Shakya's Blog

API-First Livewire Architecture: Building Maintainable Laravel Applications

API-First Livewire is an architectural approach where Livewire components act as a thin presentation layer on top of a robust, reusable service layer. This pattern ensures separation of concerns, testability, and reusability across your entire application.


Introduction

What is API-First Livewire?

API-First Livewire is an architectural approach where Livewire components act as a thin presentation layer on top of a robust, reusable service layer that could theoretically serve any client (web, mobile, CLI, etc.). This pattern ensures:

Why API-First?

Traditional Livewire (Bloated):
┌─────────────────────────────┐
│   Livewire Component        │
│   ├─ UI Logic              │
│   ├─ Validation            │
│   ├─ Business Logic        │
│   ├─ Database Queries      │
│   └─ Email/Notifications   │
└─────────────────────────────┘

API-First Livewire (Clean):
┌─────────────┐    ┌──────────────┐    ┌─────────────┐
│  Component  │───▶│   Service    │───▶│   API/DB    │
│ (UI Only)   │    │ (Business)   │    │ (Data)      │
└─────────────┘    └──────────────┘    └─────────────┘

Core Principles

1. Livewire = Presentation Layer Only

Livewire components should only handle:

❌ DON’T:

// ❌ WRONG: Business logic in component
class CreatePost extends Component
{
    public $title;
    public $content;

    public function save()
    {
        // Validation
        $this->validate([
            'title' => 'required|max:255',
            'content' => 'required',
        ]);

        // Business logic
        $post = Post::create([
            'title' => $this->title,
            'content' => $this->content,
            'user_id' => auth()->id(),
        ]);

        // Side effects
        if ($post->is_featured) {
            Notification::send($post->author, new FeaturedPostCreated($post));
        }

        // External API call
        Http::post('https://api.example.com/sync', $post->toArray());

        return $this->redirect('/posts');
    }
}

✅ DO:

// ✅ CORRECT: Thin component
class CreatePost extends Component
{
    public $title;
    public $content;

    #[Validate('required|max:255')]
    public $title;

    #[Validate('required')]
    public $content;

    public function __construct(
        private readonly PostService $postService
    ) {}

    public function save()
    {
        $this->validate();

        $this->postService->createPost([
            'title' => $this->title,
            'content' => $this->content,
        ]);

        return $this->redirect('/posts');
    }
}

2. Services are Pure Business Logic

Services should:

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\DB;
use App\Models\Post;
use App\Events\PostCreated;
use App\Notifications\PostPublished;

class PostService
{
    public function createPost(array $data): Post
    {
        return DB::transaction(function () use ($data) {
            $post = Post::create([
                'title' => $data['title'],
                'content' => $data['content'],
                'is_published' => $data['is_published'] ?? false,
            ]);

            // Handle side effects
            if ($post->is_published) {
                $this->notifySubscribers($post);
                $this->syncWithExternalAPI($post);
            }

            return $post;
        });
    }

    private function notifySubscribers(Post $post): void
    {
        // Queue notification
        PostCreated::dispatch($post);
    }

    private function syncWithExternalAPI(Post $post): void
    {
        // External API call
        Http::post('https://api.example.com/posts', [
            'post_id' => $post->id,
            'title' => $post->title,
        ]);
    }
}

3. API Layer for External Communication

Create dedicated API clients for external services:

<?php

namespace App\Services\API;

use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Response;

class ExternalPostAPI
{
    protected string $baseUrl;
    protected string $apiKey;

    public function __construct()
    {
        $this->baseUrl = config('services.external_post.base_url');
        $this->apiKey = config('services.external_post.api_key');
    }

    public function syncPost(array $data): Response
    {
        return Http::withToken($this->apiKey)
            ->timeout(10)
            ->post("{$this->baseUrl}/posts", $data);
    }

    public function getPostResults(string $postId): array
    {
        return Http::withToken($this->apiKey)
            ->timeout(10)
            ->get("{$this->baseUrl}/posts/{$postId}/results}")
            ->json();
    }
}

Architecture Overview

Complete Flow Diagram

┌─────────────────────────────────────────────────────────────┐
│                     USER INTERFACE                          │
│                  (Blade + Alpine.js)                        │
└──────────────────────┬──────────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────────┐
│                  LIVEWIRE COMPONENT                         │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ • Public Properties (UI State)                      │  │
│  │ • Wire Actions (User Events)                        │  │
│  │ • Validation Rules                                  │  │
│  │ • Event Listeners                                   │  │
│  │ • Render Logic (View Data)                          │  │
│  └──────────────────────────────────────────────────────┘  │
└──────────────────────┬──────────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────────┐
│                   SERVICE LAYER                             │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ • Business Logic                                    │  │
│  │ • Data Validation                                   │  │
│  │ • Transaction Management                            │  │
│  │ • Side Effects (Events, Jobs, Notifications)       │  │
│  │ • External API Coordination                         │  │
│  └──────────────────────────────────────────────────────┘  │
└──────────────────────┬──────────────────────────────────────┘
                       │
        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
┌─────────────┐  ┌──────────┐  ┌──────────┐
│   Database  │  │  Queue   │  │ External │
│   (Eloquent)│  │  Jobs    │  │   APIs   │
└─────────────┘  └──────────┘  └──────────┘

Directory Structure

app/
├── Livewire/
│   ├── Post/
│   │   ├── CreatePost.php
│   │   ├── EditPost.php
│   │   └── PostList.php
│   └── Comment/
│       └── CommentForm.php
├── Services/
│   ├── PostService.php
│   ├── CommentService.php
│   └── ResultService.php
├── API/
│   ├── ExternalPostAPI.php
│   └── ExternalResultAPI.php
├── Actions/
│   ├── CreatePostAction.php
│   ├── PublishPostAction.php
│   └── GradeCommentAction.php
├── DTOs/
│   ├── PostData.php
│   ├── CommentData.php
│   └── ResultData.php
├── Events/
│   ├── PostCreated.php
│   ├── PostPublished.php
│   └── ResultCalculated.php
└── Notifications/
    ├── PostPublishedNotification.php
    └── ResultReadyNotification.php

Service Layer Pattern

Service Class Structure

<?php

namespace App\Services;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Models\Post;
use App\Events\PostCreated;
use App\Services\API\ExternalPostAPI;

class PostService
{
    public function __construct(
        private readonly ExternalPostAPI $externalAPI
    ) {}

    /**
     * Create a new post with all associated data
     */
    public function createPost(array $data): Post
    {
        return DB::transaction(function () use ($data) {
            $post = Post::create([
                'title' => $data['title'],
                'content' => $data['content'] ?? null,
                'is_published' => $data['is_published'] ?? false,
            ]);

            // Handle tags if provided
            if (isset($data['tags'])) {
                $this->attachTags($post, $data['tags']);
            }

            // Dispatch event
            PostCreated::dispatch($post);

            // Sync with external API if published
            if ($post->is_published) {
                $this->syncWithExternal($post);
            }

            return $post;
        });
    }

    /**
     * Update an existing post
     */
    public function updatePost(Post $post, array $data): Post
    {
        $post->update($data);

        if (isset($data['tags'])) {
            $this->syncTags($post, $data['tags']);
        }

        return $post;
    }

    /**
     * Publish a post and notify subscribers
     */
    public function publishPost(Post $post): void
    {
        $post->update(['is_published' => true]);

        // Queue external sync
        $this->externalAPI->syncPost($post->toArray());

        // Dispatch event for notifications
        event(new PostPublished($post));
    }

    /**
     * Get post with relations
     */
    public function getPostWithRelations(int $id): Post
    {
        return Post::with(['tags', 'comments', 'author'])
            ->findOrFail($id);
    }

    /**
     * Validate post data
     */
    public function validatePostData(array $data): array
    {
        return validator($data, [
            'title' => 'required|string|max:255',
            'content' => 'required|string',
            'is_published' => 'boolean',
        ])->validate();
    }

    private function attachTags(Post $post, array $tags): void
    {
        foreach ($tags as $tagData) {
            $post->tags()->create($tagData);
        }
    }

    private function syncTags(Post $post, array $tags): void
    {
        $post->tags()->delete();
        $this->attachTags($post, $tags);
    }

    private function syncWithExternal(Post $post): void
    {
        try {
            $this->externalAPI->syncPost($post->toArray());
        } catch (\Exception $e) {
            Log::error('Failed to sync post with external API', [
                'post_id' => $post->id,
                'error' => $e->getMessage(),
            ]);
        }
    }
}

Action Classes (Complex Operations)

For complex multi-step operations, use Action classes:

<?php

namespace App\Actions;

use App\Models\Post;
use App\Services\PostService;
use App\Services\API\ExternalResultAPI;

class GradePostAction
{
    public function __construct(
        private readonly PostService $postService,
        private readonly ExternalResultAPI $externalAPI
    ) {}

    public function execute(Post $post, array $responses): array
    {
        // 1. Calculate score
        $score = $this->calculateScore($post, $responses);

        // 2. Determine pass/fail
        $passed = $score >= $post->passing_score;

        // 3. Save result
        $result = $post->results()->create([
            'user_id' => auth()->id(),
            'score' => $score,
            'passed' => $passed,
            'responses' => $responses,
        ]);

        // 4. Sync with external API
        $this->externalAPI->submitResult($result->toArray());

        // 5. Notify user
        if ($passed) {
            event(new PostPassed($post, $result));
        }

        return [
            'result' => $result,
            'passed' => $passed,
            'score' => $score,
        ];
    }

    private function calculateScore(Post $post, array $responses): int
    {
        $total = 0;
        $correct = 0;

        foreach ($post->questions as $question) {
            $total++;
            if (isset($responses[$question->id]) && 
                $responses[$question->id] === $question->correct_answer) {
                $correct++;
            }
        }

        return $total > 0 ? round(($correct / $total) * 100) : 0;
    }
}

Livewire Component Structure

1. Thin Component Pattern

<?php

namespace App\Livewire\Post;

use Livewire\Component;
use Livewire\Attributes\Validate;
use App\Services\PostService;

class CreatePost extends Component
{
    #[Validate('required|string|max:255')]
    public $title = '';

    #[Validate('nullable|string|max:1000')]
    public $content = '';

    #[Validate('boolean')]
    public $is_published = false;

    public $tags = [];

    public bool $showSuccessModal = false;

    public function __construct(
        private readonly PostService $postService
    ) {}

    public function mount(): void
    {
        // Initialize with default values if needed
        $this->tags = [['name' => '']];
    }

    public function addTag(): void
    {
        $this->tags[] = ['name' => ''];
    }

    public function removeTag(int $index): void
    {
        unset($this->tags[$index]);
        $this->tags = array_values($this->tags);
    }

    public function save(): void
    {
        $this->validate();

        try {
            $postData = [
                'title' => $this->title,
                'content' => $this->content,
                'is_published' => $this->is_published,
                'tags' => $this->tags,
            ];

            $post = $this->postService->createPost($postData);

            $this->showSuccessModal = true;
            $this->dispatch('post-created', $post->id);

            // Reset form
            $this->reset();

        } catch (\Exception $e) {
            $this->dispatch('error', $e->getMessage());
        }
    }

    public function render()
    {
        return view('livewire.post.create');
    }
}

2. List Component with Filtering

<?php

namespace App\Livewire\Post;

use Livewire\Component;
use Livewire\WithPagination;
use App\Services\PostService;
use App\Models\Post;

class PostList extends Component
{
    use WithPagination;

    #[Validate('nullable|string|max:50')]
    public $search = '';

    #[Validate('nullable|in:asc,desc')]
    public $sortDirection = 'desc';

    #[Validate('nullable|in:id,title,created_at')]
    public $sortBy = 'created_at';

    public bool $showDeleteModal = false;
    public ?int $postIdToDelete = null;

    public function __construct(
        private readonly PostService $postService
    ) {}

    public function sortBy(string $field): void
    {
        if ($this->sortBy === $field) {
            $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
        } else {
            $this->sortBy = $field;
            $this->sortDirection = 'asc';
        }
    }

    public function confirmDelete(int $postId): void
    {
        $this->postIdToDelete = $postId;
        $this->showDeleteModal = true;
    }

    public function deletePost(): void
    {
        $this->validate([
            'postIdToDelete' => 'required|integer|exists:posts,id',
        ]);

        $post = Post::findOrFail($this->postIdToDelete);
        $this->postService->deletePost($post);

        $this->showDeleteModal = false;
        $this->postIdToDelete = null;

        $this->dispatch('post-deleted');
    }

    public function getPostsProperty()
    {
        return Post::query()
            ->when($this->search, function ($query) {
                $query->where('title', 'like', "%{$this->search}%");
            })
            ->orderBy($this->sortBy, $this->sortDirection)
            ->paginate(10);
    }

    public function render()
    {
        return view('livewire.post.list', [
            'posts' => $this->posts,
        ]);
    }
}

3. Edit Component (Form Object Pattern)

<?php

namespace App\Livewire\Post;

use Livewire\Component;
use App\Services\PostService;
use App\Models\Post;
use App\Livewire\Forms\PostForm;

class EditPost extends Component
{
    public PostForm $form;

    public Post $post;

    public function __construct(
        private readonly PostService $postService
    ) {}

    public function mount(Post $post): void
    {
        $this->post = $post;
        $this->form->fill($post->toArray());
    }

    public function update(): void
    {
        $this->form->validate();

        $this->postService->updatePost($this->post, $this->form->toArray());

        $this->dispatch('post-updated');
        return $this->redirect('/posts');
    }

    public function render()
    {
        return view('livewire.post.edit');
    }
}

Form Object:

<?php

namespace App\Livewire\Forms;

use Livewire\Form;

class PostForm extends Form
{
    public $title = '';
    public $content = '';
    public $is_published = false;
    public $tags = [];

    public function rules(): array
    {
        return [
            'title' => 'required|string|max:255',
            'content' => 'required|string',
            'is_published' => 'boolean',
            'tags' => 'nullable|array',
            'tags.*.name' => 'required|string|max:50',
        ];
    }

    public function messages(): array
    {
        return [
            'title.required' => 'Please enter a post title',
            'content.required' => 'Content cannot be empty',
        ];
    }

    public function validationAttributes(): array
    {
        return [
            'title' => 'post title',
            'content' => 'content',
        ];
    }
}

Data Flow Patterns

1. Form Submission Flow

User Action (Submit)
    ↓
Livewire Component (validate())
    ↓
Service Layer (createPost())
    ↓
API/Database Layer
    ↓
Event Dispatch (PostCreated)
    ↓
Notifications/Jobs
    ↓
Return to Component
    ↓
UI Update (redirect/modal)

2. Real-Time Updates with Events

// In Component
public function publishPost(): void
{
    $this->postService->publishPost($this->post);
    
    // Broadcast to all listeners
    $this->dispatch('post-published', $this->post->id);
}

// In Another Component
#[On('post-published')]
public function refreshList($postId): void
{
    $this->posts = $this->postService->getRecentPosts();
}

Testing Strategy

1. Unit Tests for Services

<?php

namespace Tests\Unit\Services;

use Tests\TestCase;
use App\Services\PostService;
use App\Models\Post;
use Illuminate\Support\Facades\Event;

class PostServiceTest extends TestCase
{
    private PostService $service;

    protected function setUp(): void
    {
        parent::setUp();
        $this->service = new PostService();
    }

    public function test_create_post_creates_record(): void
    {
        $data = [
            'title' => 'My First Post',
            'content' => 'This is the content',
            'is_published' => true,
        ];

        $post = $this->service->createPost($data);

        $this->assertInstanceOf(Post::class, $post);
        $this->assertEquals('My First Post', $post->title);
        $this->assertDatabaseHas('posts', ['title' => 'My First Post']);
    }

    public function test_create_post_dispatches_event(): void
    {
        Event::fake();

        $data = [
            'title' => 'Test Post',
            'content' => 'Test content',
        ];

        $this->service->createPost($data);

        Event::assertDispatched(\App\Events\PostCreated::class);
    }

    public function test_create_post_with_tags(): void
    {
        $data = [
            'title' => 'Post with Tags',
            'content' => 'Content',
            'tags' => [
                ['name' => 'Laravel'],
                ['name' => 'Livewire'],
            ],
        ];

        $post = $this->service->createPost($data);

        $this->assertEquals(2, $post->tags->count());
    }
}

2. Livewire Component Tests

<?php

namespace Tests\Feature\Livewire;

use Tests\TestCase;
use Livewire\Livewire;
use App\Livewire\Post\CreatePost;
use App\Services\PostService;
use Mockery\MockInterface;

class CreatePostTest extends TestCase
{
    public function test_component_renders(): void
    {
        Livewire::test(CreatePost::class)
            ->assertStatus(200);
    }

    public function test_validation_works(): void
    {
        Livewire::test(CreatePost::class)
            ->set('title', '')
            ->call('save')
            ->assertHasErrors(['title' => 'required']);
    }

    public function test_save_calls_service(): void
    {
        $this->mock(PostService::class, function (MockInterface $mock) {
            $mock->shouldReceive('createPost')
                ->once()
                ->andReturn((object)['id' => 1]);
        });

        Livewire::test(CreatePost::class)
            ->set('title', 'Test Post')
            ->set('content', 'Test content')
            ->call('save')
            ->assertDispatched('post-created');
    }
}

Performance Optimization

1. Eager Loading

// ❌ BAD: N+1 queries
public function getPostsProperty()
{
    return Post::paginate(10); // Will query tags separately
}

// ✅ GOOD: Eager load relations
public function getPostsProperty()
{
    return Post::with(['tags', 'author'])->paginate(10);
}

2. Lazy Loading

<?php

namespace App\Livewire\Post;

use Livewire\Component;
use Livewire\WithPagination;

class PostList extends Component
{
    use WithPagination;

    public $loadTags = false;

    public function loadTags(): void
    {
        $this->loadTags = true;
    }

    public function getPostsProperty()
    {
        $query = Post::query();

        if ($this->loadTags) {
            $query->with(['tags']);
        }

        return $query->paginate(10);
    }
}

3. Caching

<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;

class PostService
{
    public function getPopularPosts(): array
    {
        return Cache::remember('popular_posts', 3600, function () {
            return Post::where('is_published', true)
                ->withCount('likes')
                ->orderBy('likes_count', 'desc')
                ->limit(10)
                ->get()
                ->toArray();
        });
    }

    public function invalidatePostCache(int $postId): void
    {
        Cache::forget("post_{$postId}");
        Cache::forget("post_{$postId}_comments");
    }
}

Security Considerations

1. Authorization in Services

<?php

namespace App\Services;

use App\Models\Post;
use Illuminate\Auth\Access\AuthorizationException;

class PostService
{
    public function updatePost(Post $post, array $data): Post
    {
        // Authorization check
        if (!auth()->user()->can('update', $post)) {
            throw new AuthorizationException('You cannot update this post');
        }

        $post->update($data);
        return $post;
    }
}

2. Input Sanitization

public function createPost(array $data): Post
{
    // Sanitize input
    $data = array_map(function ($value) {
        return is_string($value) ? strip_tags(trim($value)) : $value;
    }, $data);

    return Post::create($data);
}

3. Rate Limiting

<?php

namespace App\Livewire\Post;

use Livewire\Component;
use Livewire\Attributes\RateLimit;

class SubmitPost extends Component
{
    #[RateLimit(1, 60)] // 1 request per minute
    public function submit(): void
    {
        // Process submission
    }
}

Common Pitfalls

❌ Pitfall 1: Service Becomes Too Large

Problem: Service class grows to 1000+ lines.

Solution: Split into smaller, focused services.

// Instead of one giant service
class PostService { /* 1000 lines */ }

// Split into focused services
class PostCRUDService { /* Create, Read, Update, Delete */ }
class PostPublishingService { /* Publish, Unpublish */ }
class PostResultService { /* Calculate, Grade */ }

❌ Pitfall 2: Services Calling Services

Problem: Circular dependencies.

Solution: Use Actions for orchestration.

// ❌ BAD
class ServiceA {
    public function __construct(private ServiceB $b) {}
}

class ServiceB {
    public function __construct(private ServiceA $a) {} // Circular!
}

// ✅ GOOD
class ActionA {
    public function __construct(
        private ServiceA $a,
        private ServiceB $b
    ) {}
}

❌ Pitfall 3: Passing Livewire Components to Services

Problem: Tight coupling.

Solution: Pass data only.

// ❌ BAD
public function save(PostComponent $component)
{
    $component->validate();
    // ...
}

// ✅ GOOD
public function save(array $data)
{
    // Validate data
    // ...
}

Quick Reference

Service Template

<?php

namespace App\Services;

use Illuminate\Support\Facades\DB;

class {Model}Service
{
    public function create(array $data): {Model}
    {
        return DB::transaction(function () use ($data) {
            $model = {Model}::create($data);
            // Side effects
            return $model;
        });
    }

    public function update({Model} $model, array $data): {Model}
    {
        $model->update($data);
        return $model;
    }
}

Component Template

<?php

namespace App\Livewire\{Model};

use Livewire\Component;
use App\Services\{Model}Service;

class Create{Model} extends Component
{
    public $field;

    public function __construct(
        private readonly {Model}Service $service
    ) {}

    public function save()
    {
        $this->validate();
        $this->service->create(['field' => $this->field]);
    }
}

Conclusion

API-first Livewire architecture provides a clean, maintainable, and scalable approach to building Laravel applications. By keeping Livewire components as a thin presentation layer and delegating business logic to services, you create code that is:

Start small, extract gradually, and always keep the separation of concerns in mind. Your future self (and team) will thank you.


Document Version: 2.0.0
Last Updated: 2026-01-13