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:
- ✅ Separation of Concerns: UI logic separated from business logic
- ✅ Testability: Services can be unit tested independently
- ✅ Reusability: Business logic accessible from multiple entry points
- ✅ Maintainability: Changes to business logic don’t affect UI components
- ✅ Scalability: Easy to add new interfaces (API, console commands, etc.)
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:
- User input and form binding
- UI state management (modals, tabs, filters)
- Event dispatching/listening
- Validation rules definition
- Redirects and navigation
❌ 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:
- Be stateless
- Have single responsibility
- Accept data, return results
- Handle side effects
- Be testable in isolation
<?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:
- Testable: Services can be unit tested independently
- Reusable: Business logic accessible from multiple entry points
- Maintainable: Changes are isolated and predictable
- Scalable: Easy to add new interfaces or features
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