Getting StartedMonolith
The source code for this exercise is on GitHub.
In this guide we’re going to build an application to share and discuss food recipes that’s made up of two sections:
- Kitchen: To manage and share recipes, calculating calories and avg. price.
- Forum: To discuss recipes with fellow members and get in touch with the experts.
The purpose of this exercise is to demonstrate Lucid Monolith - the multi-purpose, service-oriented variant of Lucid. Typically used when you wish to apply separation of concerns yet still be able to share code that is necessary for all the parts such as models and business logic.
The obvious use of Monolith is to contain a large feature set within a single codebase, in contrast with Microservices where we would dissect it into several codebases and have them responsible for their own part in isolation yet integrate and communicate to get a single job done. Starting with Lucid’s Monolith grants the ability to later do the transformation, making it easier to decide on the different parts that need to go into their own [micro]services, and the code would’ve already been concentrated to minimise the move on their own.
For Micro - the single-purpose variant, see Getting Started • Micro
Setup
Install Laravel
Let’s start by creating a new Laravel project. It is best if you refer to Laravel’s installation docs and choose your preferred way of installation, but here are the common ways to do it:
# via the installer
laravel new cookery
# via composer
composer create-project --prefer-dist laravel/laravel cookery
Install Lucid
composer require lucidarch/lucid
Database Configuration
Now that we have our project ready with a .env
file we can configure the database connection.
For the brevity of this example we will utilise SQLite as it requires the least steps to get going. This is surely not recommended in real apps but will make very little difference since you can change the configuration and use your favourite database without affecting the code.
Create database file
storage/app/database/database.sql
mkdir -p storage/app/database && touch storage/app/database/database.sql
Configure database
.env
DB_CONNECTION=sqlite DB_DATABASE={ABSOLUTE PATH}/storage/app/database/database.sql
Create tables
php artisan migrate
And the output should match this:
Migration table created successfully. Migrating: 2014_10_12_000000_create_users_table Migrated: 2014_10_12_000000_create_users_table (4.11ms) Migrating: 2014_10_12_100000_create_password_resets_table Migrated: 2014_10_12_100000_create_password_resets_table (2.14ms) Migrating: 2019_08_19_000000_create_failed_jobs_table Migrated: 2019_08_19_000000_create_failed_jobs_table (2.54ms)
Initialize Monolith
Assuming that you have ./vendor/bin
in your PATH
the lucid
binary will be available to run Lucid commands.
We will start with init:monolith
to initialize directory structure and specify our first service to be created along.
lucid init:monolith Kitchen
Initializing Lucid Monolith for Laravel 8.15.0
Created directories:
/app/Data
/app/Domains
/app/Services
/app/Foundation
/app/Policies
Service Kitchen created successfully.
Activate it by adding App\Services\Kitchen\Providers\KitchenServiceProvider::class
to 'providers' in config/app.php
Let’s do as instructed and add the service’s provider to the providers
array in config/app.php
and this way Laravel will recognize our Kitchen
service and load its files.
Now if we run our application with php artisan serve
and visit /kitchen
we should be greeted by the service.
By default the service’s routes are prefixed with its name. Of course, you may modify that at app/Services/Kitchen/routes
which has the exact same structure as Laravel’s default /routes
.
Service Directory Structure
Below is the default directory structure of every service in the monolith. They look familiar because they are the same as Laravel’s default structure, intended to provide the same functionality but with a different arrangement.
The highlighted directories are those that are proprietary to Lucid as we will learn throughout this guide.
app/Services/Kitchen ├── Console │ └── Commands ├── Features ├── Operations ├── Http │ ├── Controllers │ └── Middleware ├── Providers │ ├── ApiServiceProvider.php │ ├── BroadcastServiceProvider.php │ └── RouteServiceProvider.php ├── Tests │ └── Features │ └── Operations ├── database │ ├── factories │ ├── migrations │ └── seeds ├── resources │ ├── lang │ └── views └── routes ├── api.php ├── channels.php ├── console.php └── web.php
The Service Provider
KitchenServiceProvider
is where the service tells Laravel how to locate its files such as routes, views, lang
and it may be used for anything that the service may need in the future. Having this file allows us to control
the loading of serivce files conditionally.
The service files will not be loaded by Laravel if it weren’t registered. We used the simple method of adding the provider
to the app’s providers
array which means that the service files will be registered in every interaction with our application.
This may not always be the intented behaviour, where we would want to register services conditionally.
For example, if we wish to load only Kitchen service if our domain matches kitchen.example.com
we would instead register
the provider in app/Providers/AppServiceProvider::regsiter
as such:
public function regsiter()
{
switch (Route::input('subdomain')) {
case 'kitchen':
$this->register(KitchenServiceProvider::class);
break;
case 'forum':
$this->register(ForumServiceProvider::class);
break;
}
}
Given that we define our routes with a subdomain group:
app/Services/Kitchen/routes/web.php
Route::group(['domain' => '{subdomain}.cookery.local'], function() {
// define Kitchen routes here
});
Authentication
We will try to avoid re-inventing the wheel as much as possible by using laravel/breeze
to scaffold configuration and Auth routes, views and controllers styled with TailwindCSS.
composer require laravel/breeze --dev
php artisan breeze:install
npm install && npm run dev
Now to watch assets and build on change we may run npm run watch
.
The resulting files are under resources/views/auth
and the welcome
page has been updated to look as follows:
To create an account click on Register at the top right and enter your account details to be logged in to the dashboard:
Authentication is central to our application, the same user will be accessing both the forum and the kitchen.
We’re keeping auth and User
at root for the brevity of this example but ideally we’d move the User
model to our Data/Models
directory
and adapt Auth to read it from there in config/auth.php
, though this is not necessary for now.
Recipe Submission
The first feature we will work on is to receive a recipe’s details and add it to our records. Starting with the form:
View
app/Services/Kitchen/routes/web.php
Route::group(['prefix' => 'kitchen'], function() {
Route::get('/recipes/new', function() {
return view('kitchen::new');
});
});
Next, we need to create the form at app/Services/Kitchen/resources/views/new.blade.php
with the styles:
resources/css/app.css
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@layer components {
.form-container {
@apply flex items-center justify-center h-screen;
}
.form {
@apply w-full max-w-sm bg-white shadow-md rounded px-8 pt-6 pb-8;
}
.form-label {
@apply block text-gray-500 font-bold mb-1 pr-4;
}
.form-error-status-message {
@apply bg-red-100 border border-red-400 text-red-700 px-4 py-3 mt-2 mb-2 rounded relative;
}
.form-input-row {
@apply flex items-center mb-6;
}
.form-input-error-label {
@apply text-red-500 text-xs italic mt-2;
}
.form-input {
@apply appearance-none border-2 rounded w-full py-2 px-4 text-gray-700 leading-tight border-gray-200;
}
.form-input:focus {
@apply bg-white border-teal-500 outline-none;
}
.btn {
@apply shadow bg-teal-500 text-white font-bold py-2 px-4 rounded;
}
.btn:hover {
@apply bg-teal-400
}
.btn:focus {
@apply shadow-outline outline-none;
}
}
It is recommended to scope styles per service as well, but it would’ve required a couple more steps that we’d rather
skip for being out of the scope of this guide. i.e. loading a custom CSS file from app/Services/Kitchen/resources/css/kitchen.css
app/Services/Kitchen/resources/views/new.blade.php
<x-app-layout>
<x-slot name="header">
Add Recipe
</x-slot>
<div class="form-container">
<form action="/submit" method="post" class="form">
@csrf
@if ($errors->any())
<div class="form-error-status-message" role="alert">
Please fix the following errors
</div>
@endif
<div class="form-input-row">
<div class="md:w-1/3">
<label class="form-label" for="title">
Title
</label>
</div>
<div class="md:w-2/3">
<input id="title"
type="text"
name="title"
value="{{ old('title') }}"
class="form-input">
@error('title')
<p class="form-input-error-label">{{ $message }}</p>
@enderror
</div>
</div>
<div class="form-input-row">
<div class="md:w-1/3">
<label for="ingredients" class="form-label" for="desription">
Ingredients
</label>
</div>
<div class="md:w-2/3">
<p class="text-gray-500 text-xs">each ingredient on a new line.</p>
<p class="text-gray-500 text-xs">Ingredient, mass /g, $ /g</p>
<textarea id="ingredients"
name="ingredients"
class="form-input h-48"
placeholder="Avocado, 0.5, 0.07
Lettuce, 0.3, 0.04">{{ old('ingredients') }}</textarea>
@error('ingredients')
<p class="form-input-error-label">{{ $message }}</p>
@enderror
</div>
</div>
<div class="form-input-row">
<div class="md:w-1/3">
<label for="instructions" class="form-label" for="desription">
Instructions
</label>
</div>
<div class="md:w-2/3">
<textarea id="instructions"
name="instructions"
class="form-input h-24"
placeholder="How to do it?">{{ old('instructions') }}</textarea>
</div>
</div>
<div class="md:flex md:items-center">
<div class="md:w-1/3"></div>
<div class="md:w-2/3">
<button type="submit" class="btn">
Add
</button>
</div>
</div>
</form>
</div>
</x-app-layout>
It’s a simple form:
Controller
Generate RecipeController
in Kitchen service to handle recipe requests and serve corresponding features:
lucid make:controller recipe kitchen
Controller class created successfully.
Find it at app/Services/Kitchen/Http/Controllers/RecipeController.php
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
.
We will need a method to handle the form’s submission request, let’s call it add
:
app/Services/Kitchen/Http/Controllers/RecipeController.php
<?php
namespace App\Services\Kitchen\Http\Controllers;
use Lucid\Units\Controller;
class RecipeController extends Controller
{
public function add()
{
}
}
Feature
The add
method will then serve the feature that will run the jobs and operations required to add recipes.
Generate a feature called AddRecipeFeature
in Kitchen service:
lucid make:feature AddRecipe kitchen
Feature class AddRecipeFeature created successfully.
Find it at app/Services/Kitchen/Features/AddRecipeFeature.php
add()
will now serve AddRecipeFeature
app/Services/Kitchen/Http/Controllers/RecipeController.php
use App\Services\Kitchen\Features\AddRecipeFeature;
...
public function add()
{
return $this->serve(new AddRecipeFeature());
}
Beginning with the step of generating a feature will cognitively set our context to what we will be working on, helping us concentrate on the task at hand.
Let’s expose our feature with a route:
use App\Services\Kitchen\Http\Controllers\RecipeController;
Route::group(['prefix' => 'kitchen'], function() {
Route::post('/recipes', [RecipeController::class, 'add']);
});
Database
Before we can start accepting data we need to prepare our database with a migration to create the recipes table:
We would like our kitchen tables to be managed by the Kitchen service, so we will place the migrations
in app/Services/Kitchen/database/migraitons
instead of the default database/migrations
.
This is an optional step and may not fit all cases, but the option is here when needed using the lucid make:migration
:
lucid make:migration create_recipes_table kitchen
Created Migration: 2020_11_18_211503_create_recipes_table
Find it at app/Services/Kitchen/database/migrations
Now we update the table’s schema at app/Services/Kitchen/database/migrations/{datetime}_create_recipes_table.php
to include recipe fields and a reference to the user:
Schema::create('recipes', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('ingredients');
$table->text('instructions')->nullable();
$table->decimal('price', 8, 3);
$table->unsignedBigInteger('user_id');
$table->timestamps();
$table->foreign('user_id')
->references('id')
->on('users');
});
The migration will automatically be registered so the next time we run php artisan migrate
recipes
table will be created:
php artisan migrate
Migrating: 2020_11_18_211503_create_recipes_table
Migrated: 2020_11_18_211503_create_recipes_table (5.43ms)
Model
The Data Directory app/Data
For a scalable set of interconnected data elements, with Lucid we place them in app/Data
,
because most likely over time writing the application there could develop a need for more than Models in data,
such as Repositories, Value Objects, Collections and more… which all fit in a central directory to consolidate them.
Create the Recipe
model class at app/Data/Models/Recipe.php
with the fields in $fillable
for mass assignment:
<?php
namespace App\Data\Models;
use Illuminate\Database\Eloquent\Model;
class Recipe extends Model
{
protected $fillable = ['title', 'ingredients', 'instructions', 'price', 'user_id'];
}
Request Validation
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 Recipe
containing an AddRecipe
Request class.
This will be the beginning of working with Domains in Lucid. They’re used to group Jobs and custom classes which logic is associated with certain topic according to domain-driven design.
Starting with validation, Lucid places Request classes within their corresponding domains. Let’s generate an AddRecipe
request:
About the naming of the request class, it also would be an option to name it CreateRecipe
instead of AddRecipe
to fulfill the CRUD convention, but AddRecipe
was easier to read and pronounce in this particular case.
lucid make:request AddRecipe recipe
Request class created successfully.
Find it at app/Domains/Recipe/Requests/AddRecipe.php
In AddRecipe
we’ll need to update the methods authorize()
and rules()
to validate the request and its input.
Validation rules are the essentials:
<?php
namespace App\Domains\Recipe\Requests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Foundation\Http\FormRequest;
class AddRecipe extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return Auth::check();
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'title' => ['required', 'max:255'],
'ingredients' => ['required', 'max:255'],
'instructions' => ['max:255'],
];
}
}
With our request ready, now we need AddRecipeFeature
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.
<?php
namespace App\Services\Kitchen\Features;
use Lucid\Units\Feature;
use App\Domains\Recipe\Requests\AddRecipe;
class AddRecipeFeature extends Feature
{
public function handle(AddRecipe $request)
{
}
}
Now if we visit /submit
and click Add
wihtout passing any input it will generate errors and print their messages from validation failures.
Calculate Recipe Price
To calculate the recipe price we need to do the following:
- Parse the ingredients into an associative array with:
name
,quantity
andprice
- Get each ingredient’s usage price:
quantity x price
- Sum all ingredient usage prices to get recipe price
∑ ingredient price
Value Objects
Looking at this requirement it seems that each step fits within a job, but thinking a little longer spawns the idea of Value Objects into thought. Especially that there will be data exchange between several jobs.
The prominent idea would be to have an Ingredient
object that’s passed around instead of a primitive data type - associative array.
It will make it easier for someone reading the code to tell the structure (fields) by referring to the class rather than having to guess
and run after associative array key assignment instructions to figure out the final structure.
In other words, let’s represent our Ingredient
as a Value Object! Unlike models, value objects are only to be used at run
time and never stored.
This concept is NOT related to the Lucid architecture although endorsed for scale. For more on value objects, Martin Fowler put it best in ValueObject in the domain-driven design section.
This brings up another advantage for the app/Data
directory which will hold our value objects in app/Data/Values
next to app/Data/Models
.
Create Ingredient
value object at app/Data/Values/Ingredient.php
and we make it optional to have quantity
and price
<?php
namespace App\Data\Values;
class Ingredient
{
public string $name;
public float $quantity;
public float $price;
public function __construct(string $name, float $quantity = 0, float $price = 0)
{
$this->name = $name;
$this->quantity = $quantity;
$this->price = $price;
}
}
Step 1: Parse Ingredients
Now we create a job that receives the Recipe
model and parses ingredient
field into Ingredent
instances, calling it
ParseIngredientsJob
.
Since we will be dealing with several instances, Collection
classes are a powerful superset to array and it woule be perfect
to extend and make it easier for the receiver of the results to use and extract value from.
Let’s create a specific collection for ingredients to pass around in app/Data/Collections/IngredientsCollection.php
<?php
namespace App\Data\Collections;
use Illuminate\Support\Collection;
/**
* A collection of Ingredient instances.
*/
class IngredientsCollection extends Collection
{
}
Now we can type-hint for it and visually represent what we’re expecting in this collection.
Next is ParseIngredientsJob
that will have the logic for this transformation from a string of ingredients
to IngredientsCollection
containing Ingredient
value object instances.
lucid make:job ParseIngredientsJob recipe
Job class ParseIngredientsJob created successfully.
Find it at app/Domains/Recipe/Jobs/ParseIngredientsJob.php
<?php
namespace App\Domains\Recipe\Jobs;
use Lucid\Units\Job;
use App\Data\Models\Recipe;
use App\Data\Values\Ingredient;
use App\Data\Collections\IngredientsCollection;
class ParseIngredientsJob extends Job
{
private string $ingredients;
/**
* Create a new job instance.
*
* @param string $ingredients
*/
public function __construct(string $ingredients)
{
$this->ingredients = $ingredients;
}
/**
* Execute the job.
*
* @return IngredientsCollection
*/
public function handle(): IngredientsCollection
{
$ingredients = new IngredientsCollection();
foreach (array_filter(explode("\r\n", $this->ingredients)) as $line) {
$ingredient = new Ingredient(...explode(',', $line));
$ingredients->push($ingredient);
}
return $ingredients;
}
}
We have oursleves a job that can be called anywhere we receive a string of ingredients and would like to parse it.
A bit about the code above:
array_filter(explode("\r\n", $this->ingredients))
turns new lines into array elements and removes empty onesnew Ingredient(...explode(',', $line))
creates a newIngredients
instance by turning comma separated string into parameters
The returned result from that job would look like this:
App\Data\Collections\IngredientsCollection {
items: [
0 => App\Data\Values\Ingredient {
name: "Avocado"
quantity: 0.5
price: 0.07
total: 0.035
}
1 => App\Data\Values\Ingredient {
name: "Lettuce"
quantity: 0.3
price: 0.04
total: 0.012
}
]
Step 2: Calculate Ingredient’s Total Price
To calculate an ingredient’s own total price we simply do $quantity * $price
but the question is where should this code be?
The thinking process to figure that out starts with the question “where would the next person look for this?” and the best answer to it wins.
In this case, to know the price of an ingredient, it is obvious that the first place to look is the Ingredient
value object.
Becuase if we were to put it somewhere else outside the class, the next time we visit Ingredient
we’d see that it recieves the total
price as an argument in its constructor but then we’d have to search for the place where this is being set.
Besides having an hidden dependency between classes.
Let’s update Ingredient
class to calculate its own total:
<?php
namespace App\Data\Values;
class Ingredient
{
public string $name;
public float $quantity;
public float $price;
public float $total;
public function __construct(string $name, float $quantity = 0, float $price = 0)
{
$this->name = $name;
$this->quantity = $quantity;
$this->price = $price;
$this->total = $this->calculateTotal();
}
public function calculateTotal()
{
return $this->quantity * $this->price;
}
}
Step 3: Calculate Recipe Total Price
Let’s create a job to calculate the total price from IngredientsCollection
calling it CalculateIngredientsTotalJob
and returns
a single float
value.
lucid make:job CalculateIngredientsTotalJob recipe
Job class CalculateIngredientsTotalJob created successfully.
Find it at app/Domains/Recipe/Jobs/CalculateIngredientsTotalJob.php
<?php
namespace App\Domains\Recipe\Jobs;
use Lucid\Units\Job;
use App\Data\Collections\IngredientsCollection;
class CalculateIngredientsTotalJob extends Job
{
private IngredientsCollection $ingredients;
/**
* Create a new job instance.
*
* @param IngredientsCollection $ingredients
*/
public function __construct(IngredientsCollection $ingredients)
{
$this->ingredients = $ingredients;
}
/**
* Execute the job.
*
* @return float
*/
public function handle(): float
{
return $this->ingredients->sum('total');
}
}
Run The Jobs
Now in AddRecipeFeature
we run the jobs:
public function handle(AddRecipe $request)
{
$ingredients = $this->run(new ParseIngredientsJob(
ingredients: $request->input('ingredients'),
));
$price = $this->run(new CalculateIngredientsTotalJob($ingredients));
}
Final Step: Group Jobs In Operations
It is indispensable for ParseIngredientsJob
and CalculateIngredientsTotalJob
to run together every time we need to calculate a recipe’s price.
This case spawns Operations into thought, to group these steps in one operation
that we can simply run with Lucid $this->run(new CalculateRecipePriceOperation($ingredient))
which runs jobs performing both steps above.
Moreover, we would still be able to run any of the jobs separatly in different conditions if needed.
lucid make:operation CalculateRecipePrice kitchen
Operation class CalculateRecipePriceOperation created successfully.
Find it at app/Services/Kitchen/Operations/CalculateRecipePriceOperation.php
<?php
namespace App\Services\Kitchen\Operations;
use Lucid\Units\Operation;
use App\Data\Collections\IngredientsCollection;
use App\Domains\Recipe\Jobs\ParseIngredientsJob;
use App\Domains\Recipe\Jobs\CalculateIngredientsTotalJob;
class CalculateRecipePriceOperation extends Operation
{
private string $ingredients;
public function __construct(string $ingredients)
{
$this->ingredients = $ingredients;
}
public function handle(): float
{
$ingredients = $this->run(new ParseIngredientsJob(
ingredients: $this->ingredients,
));
return $this->run(new CalculateIngredientsTotalJob($ingredients));
}
}
Then in the feature we’d replace the two jobs with just one call:
public function handle(AddRecipe $request)
{
$price = $this->run(new CalculateRecipePriceOperation(
ingredients: $request->input('ingredients'),
));
}
Save Recipes
To save the recipe we’ll create a job that saves recipes and run it in our feature, which will be added to our Recipe
domain at
app/Domains/Recipe/Jobs/SaveRecipeJob
along with its test tests/Unit/Domains/Recipes/Jobs/SaveRecipeJobTest
.
lucid make:job SaveRecipe recipe
Job class SaveRecipeJob created successfully.
Find it at app/Domains/Recipe/Jobs/SaveRecipeJob.php
Notice the naming that we’ve used with this job “SaveRecipeJob
” in contrast with “AddRecipe
”.
It is intended for reuse whenever needed by extending its functionality futher, for example UpdateRecipe
feature may be able to use the same job.
SaveRecipeJob
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\Recipe\Jobs;
use Lucid\Units\Job;
use App\Models\User;
use App\Data\Models\Recipe;
class SaveRecipeJob extends Job
{
private string $title;
private string $ingredients;
private string $instructions;
private string $price;
private User $user;
/**
* Create a new job instance.
*
* @param $title
* @param $ingredients
* @param $instructions
* @param $price
* @param User $user
*/
public function __construct($title, $ingredients, $instructions, $price, User $user)
{
$this->title = $title;
$this->ingredients = $ingredients;
$this->instructions = $instructions;
$this->price = $price;
$this->user = $user;
}
/**
* Execute the job.
*
* @return Recipe
*/
public function handle(): Recipe
{
$attributes = [
'title' => $this->title,
'ingredients' => $this->ingredients,
'instructions' => $this->instructions,
'price' => $this->price,
'user_id' => $this->user->getKey(),
];
return tap(new Recipe($attributes))->save();
}
}
The job’s signature is its constructor: __construct($title, $ingredients, $instructions, $price)
telling us what’s required to run this job.
This gets easier to read and use with PHP 7+ where we could type-hit these parameters:
public function __construct(
string $title,
string $ingredients,
string $instructions,
int $price)
And even better with PHP 8 we could use constructor property promotion and further reduce boilerplate:
public function __construct(
private string $title,
private string $ingredients,
private string $instructions,
private int $price
) {}
Note On Storage Approach
We are taking a rather untraditional approach in this example. Easy on storage by saving ingredients as plain text rather than splitting them up into their own model and building many-to-many relationships between recipes and ingredients, which may have been a better approach from a storage perspective. However, for the sake of demonstrating a more complex case where we split them up and represent them in value objects, because the relational approach is a familiar one and this example covers a wider range for variety reasons.
Then we’ll run this job from the feature to save recipes when served:
public function handle(AddRecipe $request)
{
$price = $this->run(new CalculateRecipePriceOperation(
ingredients: $request->input('ingredients'),
));
$this->run(new SaveRecipeJob(
title: $request->input('title'),
ingredients: $request->input('ingredients'),
instructions: $request->input('instructions'),
price: $price,
user: Auth::user(),
));
}
Calling $this->run($unit, $params)
in a feature triggers the underlying dispatcher to run SaveRecipeJob
instantly and syncronously 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(new SaveRecipeJob(
instructions: $request->input('instructions'),
ingredients: $request->input('ingredients'),
price: $price,
title: $request->input('title'),
));
You may call jobs (and other units) from any class by
supplying Lucid\Bus\UnitDispatcher
trait in the class which will equip the run($unit, $params)
function to run jobs and operations.
With this, the class in Lucid terms is now called a custom dispatcher.
The last step is to redirect a successful request back to the 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 a 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 withInput
to determine whether input 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
{
/**
* @var bool
*/
private $withInput;
/**
* Create a new job instance.
*
* @param bool $withInput
*/
public function __construct($withInput = false)
{
$this->withInput = $withInput;
}
/**
* Execute the job.
*/
public function handle()
{
$back = back();
if ($this->withInput) {
$back->withInput();
}
return $back;
}
}
Finally, conclude the feature by responding:
public function handle(AddRecipe $request)
{
$price = $this->run(new CalculateRecipePriceOperation(
ingredients: $request->input('ingredients'),
]);
$this->run(new SaveRecipeJob(
user: Auth::user(),
price: $price,
title: $request->input('title'),
ingredients: $request->input('ingredients'),
instructions: $request->input('instructions'),
]);
return $this->run(new RedirectBackJob());
}
Testing
If you visit /kitchen/recipes/new
and fill the form it should now add the recipes, but to be certain about the functionality we just built
it is necessary that we write some tests to ensure it continues to.
Configure PHPUnit
First we need to configure the database to run in a memory SQLite database instance, in phpunit.xml
uncomment the following lines:
<server name="DB_CONNECTION" value="sqlite"/>
<server name="DB_DATABASE" value=":memory:"/>
Unit Tests
Jobs in Lucid are units, and their tests are that of a unit test where we verify that all the variations of the data it might receive wouldn’t misbehave unexpectedly.
Runnig tests prior to configuring phpunit.xml
as mentioned in Configure PHPUnit
will wipe out the data that is currently in your database.
ParseIngredientsJobTest
Let’s write a test for ParseIngredientsJob
to make sure that it’s failsafe, in tests/Unit/Domains/Recipe/Jobs/ParseIngredientsJobTest
which has already been created by lucid
when generating the job. The test should cover three conditions:
- successful parsing
- parsing empty string of ingredients
- failsafe parsing with missing values
<?php
namespace Tests\Unit\Domains\Recipe\Jobs;
use Tests\TestCase;
use App\Data\Collections\IngredientsCollection;
use App\Domains\Recipe\Jobs\ParseIngredientsJob;
class ParseIngredientsJobTest extends TestCase
{
public function test_parse_ingredients_job()
{
$input = "Avocado, 1, 1.2\r\nLettuce, 0.4, 0.8";
$job = new ParseIngredientsJob($input);
$ingredients = $job->handle();
$this->assertInstanceOf(IngredientsCollection::class, $ingredients);
$this->assertEquals('Avocado', $ingredients[0]->name);
$this->assertEquals(1.0, $ingredients[0]->quantity);
$this->assertEquals(1.2, $ingredients[0]->price);
$this->assertEquals(1.2, $ingredients[0]->total);
$this->assertEquals('Lettuce', $ingredients[1]->name);
$this->assertEquals(0.4, $ingredients[1]->quantity);
$this->assertEquals(0.8, $ingredients[1]->price);
$this->assertEquals(0.32, $ingredients[1]->total);
}
public function test_parsing_empty_ingredients()
{
$job = new ParseIngredientsJob("");
$ingredients = $job->handle();
$this->assertInstanceOf(IngredientsCollection::class, $ingredients);
$this->assertTrue($ingredients->isEmpty());
}
public function test_failsafe_parsing_ingredients_with_missing_values()
{
$input = "Missing Price, 1\r\nOnly Title\r\n";
$job = new ParseIngredientsJob($input);
$ingredients = $job->handle();
$this->assertInstanceOf(IngredientsCollection::class, $ingredients);
$this->assertEquals(2, $ingredients->count());
$this->assertEquals('Missing Price', $ingredients[0]->name);
$this->assertEquals(1.0, $ingredients[0]->quantity);
$this->assertEquals(0, $ingredients[0]->price);
$this->assertEquals(0, $ingredients[0]->total);
$this->assertEquals('Only Title', $ingredients[1]->name);
$this->assertEquals(0, $ingredients[1]->quantity);
$this->assertEquals(0, $ingredients[1]->price);
$this->assertEquals(0, $ingredients[1]->total);
}
}
There is only one case where parsing fails with this implementation:
(i.e. Avocado, ,2.5
) it is when skipping values. Consider it an exercise to improve the code! It was left out in this example to not complicate logic further.
For more on testing jobs visit jobs#testing.
The tests for the rest of the jobs are available in the source code.
CalculateRecipeOperationTest
Testing operations is fairly similar and also fits among unit testing. We’ll right the tests in the generated file
tests/Unit/Services/Kitchen/Operations/CalculateRecipeOperationTest
<?php
namespace Tests\Unit\Services\Kitchen\Operations;
use Tests\TestCase;
use App\Services\Kitchen\Operations\CalculateRecipePriceOperation;
class CalculateRecipeOperationTest extends TestCase
{
public function test_calculate_recipe_operation()
{
$input = "Avocado, 1, 1.2\r\nLettuce, 0.4, 0.8";
$op = new CalculateRecipePriceOperation($input);
$this->assertEquals(1.52, $op->handle());
}
public function test_calculating_empty_recipe_operation()
{
$op = new CalculateRecipePriceOperation("");
$this->assertEquals(0.0, $op->handle());
}
public function test_calculating_recipe_with_missing_values_operation()
{
$input = "Missing Price, 1\r\nOnly Title\r\n";
$op = new CalculateRecipePriceOperation($input);
$this->assertEquals(0.0, $op->handle());
}
}
For more on testing operations visit operations#testing
Feature Test
The last test if the feature’s behaviour with different input variations and make sure that all responses are as expected. In principle, Lucid’s feature tests are about testing the integration between the units that the feature runs (jobs and operations).
Starting with our test layout as a plan to what we will be examining and prepare the test class by including RefreshDatabase
trait:
tests/Feature/Services/Kitchen/AddRecipeFeatureTest.php
<?php
namespace Tests\Feature\Services\Kitchen;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class AddRecipeFeatureTest extends TestCase
{
use RefreshDatabase;
public function test_guest_cannot_submit_a_recipe()
{
$this->markTestIncomplete();
}
public function test_recipe_is_not_created_if_validation_fails()
{
$this->markTestIncomplete();
}
public function test_max_length_fails_when_too_long()
{
$this->markTestIncomplete();
}
public function test_max_length_succeeds_when_under_max()
{
$this->markTestIncomplete();
}
}
Now we’ll just fill the tests with corresponding calls and assertions:
We expect a guest to not be able to submit recipes since in our Request class AddRecipe::authorize()
requires authorization:
public function test_guest_cannot_submit_a_recipe()
{
$response = $this->post('/kitchen/recipes', [
'title' => 'Avocado Salad Starter',
'ingredients' => "Avocado, 1, 1.2\r\nLettuce, 0.4, 0.8",
'instructions' => 'Mix it with oil and enjoy!',
]);
$response->assertStatus(403);
$response->assertSee('This action is unauthorized.');
}
Ensure input validation is as expected:
public function test_recipe_is_not_created_if_validation_fails()
{
$response = $this->actingAs(User::factory()->create())->post('/kitchen/recipes');
$response->assertSessionHasErrors(['title', 'ingredients']);
}
Finally, test input strings lengths:
public function test_max_length_fails_when_too_long()
{
$title = str_repeat('a', 256);
$ingredients = str_repeat('a', 256);
$instructions = str_repeat('a', 256);
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/kitchen/recipes', compact('title', 'ingredients', 'instructions'));
$response->assertSessionHasErrors([
'title' => 'The title may not be greater than 255 characters.',
'ingredients' => 'The ingredients may not be greater than 255 characters.',
'instructions' => 'The instructions may not be greater than 255 characters.',
]);
}
public function test_max_length_succeeds_when_under_max()
{
$data = [
'title' => str_repeat('a', 255),
'ingredients' => str_repeat('a', 255),
'instructions' => str_repeat('a', 255),
];
$this->actingAs(User::factory()->create())->post('/kitchen/recipes', $data);
$this->assertDatabaseHas('recipes', $data);
}
Conclusion
We’ve covered a breadth of Lucid features with this exercise, however it only covered the Kitchen
service.
We are left with the Forum service, which is similar in process to Kitchen, just different business logic.
For that reason we’ll leave it to you to test your familiarity with the architecture, and the full source code
is present as a reference Getting Started - Monolith.
Highlights
Application logic is segregated in services that makes it extremely simple to find corresponding logic when needed.
We are still using Laravel’s internals and
artisan
for most of the things we did. This is intended to show how Lucid preserves Laravel’s defaults and avoids replicating or replacing them; in fact it complements them by elevating their presence and fitting them within a defined structure.Navigating services and their features couldn’t get any easier, by looking at the
app/Services/*/Features
directory you’d be able to have a summary of what this application does at a glance.Visiting a feature’s class would also provide an overview of the steps required with the least details possible, yet details are available when wanting to dig deeper. In addition to the presence of tests that mirror these classes which makes it easy to update functionality with reduced unintentional negative impact.
Expanding on functionality such as
SaveRecipeJob
makes it easy to achieve a high degree of reusable code. Consider updating a recipe, we’d still use this job to save an existing recipe by supplying an optionalid
parameter and have the job create or update accordingly.