How to build a Laravel application using Lucid architecture
Exploring the micro variant in lucid architecture to build scalable laravel applications using a blog as an example
Table of contents
No headings in the article.
In this article, we'll build a blogging app using lucid architecture - micro variants.
Architecture is a gathering of connected structures or the art and processes of building something. In this tutorial's context, we're looking at the art and processes of building a laravel application.
Lucid is from Vinelab authored by @mulkave with Abed Halawi - The lucid architecture for building scalable applications - Laracon EU 2016 as the Tech lead.
"Lucid is a software architecture to build scalable Laravel projects.
It incorporates Command Bus and Domain Driven Design at the core, upon which it builds a stack of directories and classes to organize business logic. It also derives from SOA (Service Oriented Architecture) the notion of encapsulating functionality within a service and enriches the concept with more than the service being a class."
Use Lucid to:
Write clean code effortlessly
Protect your code from deteriorating over time
Review code in fractions of the time typically required
Incorporate proven practices and patterns in your applications
Navigate code and move between codebases source
Concept
This architecture is in an amalgamation of best practices, design patterns and proven methods that we decided to incorporate at the forefront of our code.
Lucid’s built-in patterns are Command Bus and Domain Driven Design, upon which it builds a stack of directories and units to organize business logic. It also derives from SOA (Service Oriented Architecture) the notion of encapsulating functionality within a “service” and enriches the concept with more than the service being a class.
Command Bus: to dispatch units of work. In Lucid terminology, these units will be a Feature, Job or Operation.
Domain Driven Design: to organize the units of work by categorizing them according to the topic they belong to.
Service Oriented Architecture: to encapsulate and manage functionalities of the same purpose with their required resources (routes, controllers, views, database migrations etc.)
Position
In a typical MVC application, Lucid will be the bond between the application’s entry points and the units that do the work, securing code from meandering in drastic directions:
The Stack
Read more on Lucid Concept or The Lucid Architecture — Concept by Abed Halawi
Philosophy
We wanted something simple [KISS] yet effective at scale. Enjoyable to work with by all levels of engineers alike. And elevates the level of understanding abstraction throughout its steep learning process. Simply put:
Lucid is a set of principles that depend on our discipline to preserve
More about Lucid Philosophy
Principles
Features shall serve a single purpose
Jobs shall perform a single task
Domains shouldn’t cross
Features shall not call other features
Jobs shall not call other jobs
Operations shall not call other operations
Controllers serve Features
Write code that humans can read
Micro
A single-purpose project that contains a moderate amount of functionalities that fit in a similar context.
It contains the fundamental units of the Lucid stack: Domains, Features, Operations and Data, complementing the Laravel framework:
When To Use Micro?
Micro is suitable for most projects, including quick prototypes that are ought to become actual products at some point which is where Lucid comes in to reduce the technical debt imposed over time. Or API projects that are meant to be organized for scale.
Also as the name suggests they are best suitable for Microservices, where you would have multiple instances of Laravel • Lucid Micro, each representing a microservice in your system.
Installation
The Lucid Architecture is delivered as a Composer package lucidarch/lucid. In a bit to get on board with how lucid microarchitecture is used to build laravel applications, we'll be building a simple blog application.
Let's start with a fresh laravel 9 app installation
laravel new lucid_architecture_laravel_app
Next, install Lucid
composer require lucidarch/lucid
Make sure to place Composer’s project vendor bin directory in your $PATH so the lucid executable can be located by your system. Usually, it’s done by running export PATH="$PATH:./vendor/bin" to have it available in your current Terminal session, or add it to your corresponding Terminal profile (e.g. ~/.bashrc, ~/.bash_profile, ~/.zshrc) to have it permanently loaded with every session.
export PATH="$PATH:./vendor/bin"
Next, Initialize a Micro instance:
Lucid Micro is the default variant for single-purpose applications.
lucid init:micro
This will generate an initial Micro structure:
Next, serve the application
php artisan serve
Lucid Stack
In an effort to minimize the work of controllers, for they are not here to do work for the application but to point the request in the right direction. In Lucid terms, to serve the intended feature to the user.
Eventually, we will end up having one line within each controller method, achieving the thinnest form possible.
Responsibility: Serve the designated feature.
Represents a human-readable app feature in a class, named the way you would describe it to your colleagues and clients. It contains the logic of implementing the feature with minimum friction and level of detail to remain concise and straight to the point.
It runs Lucid Units: Jobs and Operations to perform its tasks. They are thought of as the steps in the process of serving its purpose. A Feature can be served from anywhere, most commonly Controllers and Commands. Can also be queued to run asynchronously using Laravel’s powerful Queueing capabilities.
Technically, it is a class that encapsulates all the functionalities required for a single request/response lifecycle (or command), in which the handle method represents the task list when you want to implement it in your application.
Jobs do the actual work by implementing the business logic. Being the smallest unit in Lucid, a Job should do one thing, and one thing only - that is: perform a single task. They are the snippets of code we wish we had sometimes, and are pluggable to use anywhere in our application.
Our objective with Jobs is to limit the scope of a single functionality so that we know where to look when finding it, and when we’re within its context we don’t tangle responsibility with other jobs to achieve the ultimate form of single responsibility principle.
Usually called by a Feature or an Operation, but can be called from anywhere by any other class once setup as a custom dispatcher; hence, being the most shareable pieces of code.
Inspired by Domain-Driven Development, this piece of the Lucid stack is merely directories that are all about organizing and categorizing code according to the topic they belong to.
There is no specific way of naming them because it differs per case, but according to our experience over the years we found that they’re usually of two types, the “internal” and the “external” type of domain. Here are a few examples to illustrate the difference
Request Similar to the Laravel Form Validation we can create a custom request using the lucid commands to keep them organized.
From the Lucid stack we’d be using Features, Jobs, Domains and Requests to implement the following:
create a post
list posts
update a post
delete a post
Next, set the environment variable for this application on the .env file
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=lucid_architecture_laravel_app
DB_USERNAME=root
DB_PASSWORD=
Next, create the posts table
php artisan make:migration create_posts_table --create=posts
Add the following columns
$table->foreignId('user_id');
$table->string('title');
$table->text('description');
Next, run the migration command
php artisan migrate
Next, use the lucid command to create the PostController to manage posts and have it ready for serving features.
lucid make:controller Post
Find it at app/Http/Controllers/PostController.php
<?php
namespace App\Http\Controllers;
use Lucid\Units\Controller;
class PostController extends Controller
{}
Notice the automatic addition of Controller suffix, used as a naming convention to match other suffixes in the Lucid stack such as Feature, Job and Operation.
Next, create the Post model using the lucid command
lucid make:model Post
Update the Post model
<?php
namespace App\Data\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Post extends Model
{
use HasFactory;
protected $fillable = [
'title', 'description'
];
//setup the relationship with the users table
public function user()
{
//User model comes as default in laravel but you may wish to use the lucid command to create one
return $this->belongsTo(User::class);
}
//cast the created at attribute to be readable by humans
public function created_at(): Attribute
{
return new Attribute(
get: fn ($value) => $value->diffForHumans(),
);
}
}
Update the User Model
<?php
namespace App\Models;
//models created using lucid command are placed in the data folder
use App\Data\Models\Post;
use Laravel\Sanctum\HasApiTokens;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected $casts = [
'email_verified_at' => 'datetime',
];
public function posts()
{
return $this->hasMany(Post::class);
}
}
Next, set up the routes for the different sections of the blog application in the routes/web.php file
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\PostController;
Route::controller(PostController::class)->group(function () {
Route::get('/', 'all_posts');
Route::get('/home', 'index')->name('home');
});
Route::middleware(['auth', 'verified'])->group(function () {
Route::resource('posts', PostController::class);
});
Next, let's create a feature using lucid command and return the create post form view
lucid make:feature CreatePostFeature
Feature class CreatePostFeature created successfully.
public function create()
{
return $this->serve(CreatePostFeature::class);
}
CreatePostFeature.php
<?php
namespace App\Features;
use Lucid\Units\Feature;
class CreatePostFeature extends Feature
{
public function handle()
{
return view('posts.create');
}
}
Next, store the post
lucid make:feature StorePostFeature
Feature class StorePostFeature created successfully.
public function store()
{
return $this->serve(StorePostFeature::class);
}
Create Request Validation for the form submission
The first step of receiving input is to validate it. We will be using Form Request validation where each Request belongs in a Domain representing the entity that’s being managed, in this case, it’s Post containing a StorePost Request class.
This will be the beginning of working with Domains in Lucid. They’re used to group Jobs and custom classes whose logic is associated with certain topics according to domain-driven design.
Starting with validation, Lucid places Request classes within their corresponding domains. Let’s generate a StorePost request:
lucid make:request StorePost Post
Request class created successfully.
Find it at app\Domains\Post\Requests\StorePost.php
In StorePost we’ll need to update the methods authorize() and rules() which are created by default to validate the request and its input:
<?php
namespace App\Domains\Post\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePost extends FormRequest
{
public function authorize()
{
return true;
}
public function rules()
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['required', 'string'],
];
}
}
With our request ready, now we need StorePostFeature to use that request class when served. We can do that by simply injecting the request class in the feature’s handle() method and every time the feature is served this validation will be applied.
public function handle(StorePost $request)
Save Post
To save the post we’ll create a job that saves posts and run it in our feature, which will be added to our Post domain at app/Domains/Post/Jobs/SavePostJob.
lucid make:job SavePost Post
Job class SavePostJob created successfully.
Find it at app\Domains\Post\Jobs\SavePostJob.php
SavePostJob should define the parameters that are required in its constructor, a.k.a the job’s signature, rather than accessing the data from the request so that we can call this job from other places in our application (e.g. from a command or a custom class) and not be restricted by the protocol, in this case HTTP request.
We use this technique to increase the degree of job isolation and secure the single responsibility principle.
<?php
namespace App\Domains\Post\Jobs;
use Lucid\Units\Job;
use App\Data\Models\Post;
use Illuminate\Support\Facades\Auth;
class SavePostJob extends Job
{
public function __construct(public $title, public $description)
{}
public function handle()
{
$attributes = [
'title' => $this->title,
'description' => $this->description,
];
$post = new Post($attributes);
$user = Auth::user();
return $user->posts()->save($post);
}
}
Then we’ll run this job from the feature to save posts when received:
<?php
namespace App\Features;
use Lucid\Units\Feature;
use App\Domains\Post\Jobs\SavePostJob;
use App\Domains\Post\Requests\StorePost;
class StorePostFeature extends Feature
{
public function handle(StorePost $request)
{
$this->run(SavePostJob::class, [
'title' => $request->input('title'),
'description' => $request->input('description'),
]);
}
}
Calling $this->run($unit, $params) in a feature causes the underlying dispatcher to run SavePostJob synchronously by calling its handle method after initializing it with the provided $params which are passed as an associative array where the keys must match the job’s constructor parameters in naming, but not the order. So this would still work the same:
$this->run(SavePostJob::class, [
'description' => $request->input('description'),
'title' => $request->input('title'),
]);
The next step is to redirect a successful request back to the create form. To do that we’ll create a RedirectBackJob which will simply call back(). Even though it might seem like an overhead, but remember that we’re setting up for scale, and as we scale, the less free-form code we have the better; instead of having plenty of back() and back()->withInput() and other calls, we centralize them in a job so that in case we ever wanted to modify that functionality or add to it, the change will only need to happen in one place.
RedirectBackJob will reside in a new Http domain, a place for all our HTTP-related functionality that isn’t specific to a business entity of our application, fits the abstract type of domains instead of the entity type.
lucid make:job RedirectBackJob http
Job class RedirectBackJob created successfully.
Find it at app\Domains\Http\Jobs\RedirectBackJob.php
Our job will provide the option withMessage to determine whether message should be included in the redirection. This is a simple example of how such a simple job may later provide functionality that can be shared across the application.
<?php
namespace App\Domains\Http\Jobs;
use Lucid\Units\Job;
class RedirectBackJob extends Job
{
//PHP 8 constructor
public function __construct(private $withMessage){}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$back = back();
if ($this->withMessage) {
//custom success message
$back->with('success', $this->withMessage);
}
return $back;
}
}
Next, let's update the StorePostFeature to make use of the redirect job after a post have been created
<?php
namespace App\Features;
use Lucid\Units\Feature;
use App\Domains\Post\Jobs\SavePostJob;
use App\Domains\Post\Requests\StorePost;
use App\Domains\Http\Jobs\RedirectBackJob;
class StorePostFeature extends Feature
{
public function handle(StorePost $request)
{
$this->run(SavePostJob::class, [
'title' => $request->input('title'),
'description' => $request->input('description'),
]);
return $this->run(RedirectBackJob::class, [
'withMessage' => 'Post created successfully.',
]);
}
}
Next, create a method to handle the displaying of all posts by users, let’s call it all_posts:
public function all_posts(){
return $this->serve(AllPostsFeature::class);
}
Create the AllPostsFeature to be served by the all_posts method to return all posts
lucid make:feature AllPostsFeature
AllPostsFeature.php
<?php
namespace App\Features;
use Lucid\Units\Feature;
use App\Data\Models\Post;
class AllPostsFeature extends Feature
{
//The handle method is automatically created by the Lucid command which we'll use to return all the posts
public function handle()
{
$posts = Post::latest()->get();
return view('home', compact('posts'));
}
}
Update the all_posts method in the PostController
public function all_posts(){
return $this->serve(AllPostsFeature::class);
}
Next, create an index method to handle the listing of posts created by the user:
public function index()
{}
Feature
The index method will then serve the feature that will run the jobs required to display the posts. Generate a feature called PostsIndexFeature:
lucid make:feature PostsIndexFeature
Feature class PostsIndexFeature created successfully.
Find it at app\Features\PostsIndexFeature.php
Next update the index method to serve the posts index feature
<?php
namespace App\Http\Controllers;
use Lucid\Units\Controller;
use App\Features\PostsIndexFeature;
class PostController extends Controller
{
public function index(){
return $this->serve(PostsIndexFeature::class);
}
}
The handle method in the PostsIndexFeature is automatically created by the Lucid command which we'll use to return all the posts
<?php
namespace App\Features;
use Lucid\Units\Feature;
use App\Data\Models\Post;
use Illuminate\Support\Facades\Auth;
class PostsIndexFeature extends Feature
{
public function handle()
{
$posts = Post::where('user_id', Auth::user()->id)->latest()->get();
return view('posts.index', compact('posts'));
}
}
**The same approach to using features, jobs, requests mentioned above is used in the following sections: **
Show a post section:
lucid make:feature ShowPostFeature
Feature class ShowPostFeature created successfully.
Find it at app\Features\ShowPostFeature.php
Update the ShowPostFeature.php
<?php
namespace App\Features;
use Lucid\Units\Feature;
use App\Data\Models\Post;
class ShowPostFeature extends Feature
{
public function __construct(
public Post $post,
) {
}
public function handle()
{
return view('posts.show')->with('post', $this->post);
}
}
Show Method in the post controller
public function show(Post $post)
{
return $this->serve(ShowPostFeature::class, [
'post' => $post,
]);
}
The Edit and Update Post section:
lucid make:feature EditPostFeature
Feature class EditPostFeature created successfully.
Find it at app\Features\EditPostFeature.php
Update the EditPostFeature.php
<?php
namespace App\Features;
use Lucid\Units\Feature;
use App\Data\Models\Post;
class EditPostFeature extends Feature
{
public function __construct(
public Post $post,
) {
}
public function handle()
{
return view('posts.edit')->with('post', $this->post);;
}
}
The edit method in the PostController
public function edit(Post $post)
{
return $this->serve(EditPostFeature::class, [
'post' => $post,
]);
}
The update method in the PostController
lucid make:feature UpdatePostFeature
Feature class UpdatePostFeature created successfully.
Find it at app\Features\UpdatePostFeature.php
Create an UpdatePost job
lucid make:job UpdatePost Post
Job class UpdatePostJob created successfully.
Find it at app\Domains\Post\Jobs\UpdatePostJob.php
UpdatePostJob.php
<?php
namespace App\Domains\Post\Jobs;
use Carbon\Carbon;
use Lucid\Units\Job;
use App\Data\Models\Post;
class UpdatePostJob extends Job
{
public function __construct(public Post $post, public $title, public $description){}
public function handle()
{
$attributes = [
'title' => $this->title,
'description' => $this->description,
'created_at' => Carbon::now(),
'deleted_at' => Carbon::now(),
];
return $this->post->update($attributes);
}
}
Update the UpdatePostFeature.php
<?php
namespace App\Features;
use Lucid\Units\Feature;
use App\Data\Models\Post;
use App\Domains\Post\Jobs\UpdatePostJob;
use App\Domains\Post\Requests\StorePost;
use App\Domains\Http\Jobs\RedirectBackJob;
class UpdatePostFeature extends Feature
{
public function __construct(
public Post $post,
) {
}
public function handle(StorePost $request)
{
$this->run(UpdatePostJob::class, [
'post' => $this->post,
'title' => $request->input('title'),
'description' => $request->input('description'),
]);
return $this->run(RedirectBackJob::class, [
'withMessage' => 'Post updated successfully.',
]);
}
}
update method in the PostController
public function update(Post $post)
{
return $this->serve(UpdatePostFeature::class, [
'post' => $post,
]);
}
Delete a post section:
lucid make:feature DeletePostFeature
Feature class DeletePostFeature created successfully.
Find it at app\Features\DeletePostFeature.php
Update the DeletePostFeature.php file
<?php
namespace App\Features;
use Lucid\Units\Feature;
use App\Data\Models\Post;
class DeletePostFeature extends Feature
{
public function __construct(
public Post $post,
) {
}
public function handle()
{
$this->post->delete();
return redirect()->route('posts.create')
->with('success', 'Post deleted!!!');
}
}
To delete a post let's use the destroy method
Please note for real production app ensure to implement Laravel soft delete
public function destroy(Post $post)
{
return $this->serve(DeletePostFeature::class, [
'post' => $post,
]);
}
Update the DeletePostFeature.php
<?php
namespace App\Features;
use Lucid\Units\Feature;
use App\Data\Models\Post;
class DeletePostFeature extends Feature
{
public function __construct(
public Post $post,
) {
}
public function handle()
{
$this->post->delete();
return redirect()->route('posts.create')
->with('success', 'Post deleted!!!');
}
}
Super cool!!! that's all for the micro section more information about lucid architecture check their documentation
Let's serve the application and use it
Create post
All posts
Show post with edit and delete feature
Tutorial Repository - Lucid-Micro branch
Research credits
Abed Halawi - The lucid architecture for building scalable applications - Laracon EU 2016
Find this helpful or resourceful?? kindly share and feel free to use the comment section for questions, answers, and contributions.