I get asked a lot about how you work with Laravel. So in this tutorial, I will walk through my typical approach to building a Laravel application. We will create an API because it is something I love doing.
The API we are building is a basic to-do style application, where we can add tasks and move them between to do and done. I am choosing such a simple example because I would like you to focus on the process more than the implementation itself. So let us get started.
For me, it always begins with a simple command:
1laravel new todo-api --jet --git
For this, I would typically choose Livewire, as I am most comfortable with it. If I am honest - this application's web functionality will be just for user management and API token creation. However, feel free to use Inertia if you are more comfortable and want to follow along.
Once this command has run and everything is set up and ready for me, I open this project in PHPStorm. PHPStorm is my go-to IDE as it provides a robust set of tools for PHP development that help me with my workflow. Once this is in my IDE, I can start the work process.
The first step for me in every new application is to open the README file and start documenting what I want to achieve. This includes:
A general description of what I want to build.
Any data models that I know I will need
A rough design of the API endpoints I will need to create.
Let's explore the data models that I need to create first. I typically document these as YAML code blocks, as it allows me to describe the model in a friendly and easy way.
The Task Model will be relatively straightforward:
1Task: 2 attributes: 3 id: int 4 title: string 5 description: text (nullable) 6 status: string 7 due_at: datetime (nullable) 8 completed_at: datetime 9 relationships:10 user: BelongTo11 tags: BelongsToMany
Then we have the Tags Model, which will be a way for me to add a sort of taxonomy system to my tasks for easy sorting and filtering.
1Tag:2 attributes:3 id: int4 name: string5 relationships:6 tasks: BelongsToMany
Once I understand my data models, I start going through the dependencies I know I will need or want to use for this application. For this project, I will be using:
Laravel Sail
Laravel Pint
Larastan
JSON-API Resources
Laravel Query Builder
Fast Paginate
Data Object Tools
These packages set me up for building an API in a very friendly and easy-to-build way. From here, I can start building exactly what I need.
Now that my base Laravel application is set up for success, I can start publishing stubs that I commonly use and customize them to save me time during the development process. I tend to delete the stubs I know I will not use here and modify only the ones I know I will use. This saves me a lot of time going through the stubs that I don't need to change.
The changes that I typically add to these stubs are:
Adding declare(strict_types=1);
to each file.
Making all generated class final
by default.
Ensure that response types are always there.
Ensure that parameters are type hinted.
Ensure that any Traits are loaded one per use case.
Once this process has been completed, I work through all of the files currently in the Laravel application - and make similar changes as I did to the stubs. Now, this might take a little bit of time, but I find it is worth it, and I have a thing for strict, consistent code.
Once I have finally gotten through all of the above, I can start adding my Eloquent Models!
1php artisan make:model Task -mf
My typical workflow with the data modeling is to start with the database migrations, move onto factories, and finally, the Eloquent Models. I like to organize my data migrations in a specific way - so I will show you the example for the Tasks migration:
1public function up(): void 2{ 3 Schema::create('tasks', static function (Blueprint $table): void { 4 $table->id(); 5 6 $table->string('name'); 7 $table->text('description')->nullable(); 8 9 $table->string('status');10 11 $table12 ->foreignId('user_id')13 ->index()14 ->constrained()15 ->cascadeOnDelete();16 17 $table->dateTime('due_at')->nullable();18 $table->dateTime('completed_at')->nullable();19 $table->timestamps();20 });21}
The way this structure works is:
Identifiers
Text content
Castable properties
Foreign Keys
Timestamps
This allows me to look at any database table and know roughly where a column might be located without searching the entire table. This is something I would call a micro-optimization. Not something you will get substantial time benefits from - but it will start forcing you to have a standard and know where things are straight away.
One thing I know I will want for this API, especially regarding tasks, is a status Enum that I can use. However, the way I work with Laravel is very similar to Domain Driven Design, so there is a little setup I will need to do beforehand.
Inside my composer.json
file, I create a few new namespaces that have different purposes:
Domains
- Where my Domain-specific implementation code lives.Infrastructure
- Where my Domain specific interfaces live.ProjectName
- Where code specific to overriding specific Laravel code lives; in this case, it is called Todo
.
Eventually, you will have the following namespaces available:
1"autoload": { 2 "psr-4": { 3 "App\\": "app/", 4 "Domains\\": "src/Domains/", 5 "Infrastructure\\": "src/Infrastructure/", 6 "Todo\\": "src/Todo/", 7 "Database\\Factories\\": "database/factories/", 8 "Database\\Seeders\\": "database/seeders/" 9 }10},
Now that it is done, I can start thinking about the domains I want to use for this relatively simple app. Some would say that using something like this for such a simple application is overkill, but it means if I add to it, I do not have to do large refactors. The added benefit is that my code is always organized the way I expect it to be, no matter the application size.
The domains which we will want to use for this project can be designed like the following:
Workflow; anything to do with tasks and units of work.
Taxonomy; anything to do with categorization.
The first thing I need to do in my project is to create an Enum for the Task status attribute. I will create this under the Workflow
domain, as this is directly related to the tasks and workflows.
1declare(strict_types=1);2 3namespace Domains\Workflow\Enums;4 5enum TaskStatus: string6{7 case OPEN = 'open';8 case CLOSED = 'closed';9}
As you can see, it is quite a simple Enum, but a valuable one if I ever want to extend the capabilities of the to-do app. From here, I can set up the Model Factory and Model itself, using Arr::random
to select a random state for the Task itself.
Now we have started our data modeling. We understand the relations between authenticated users and the initial resources they have available to them. It is time to start thinking about the API design.
This API will have a handful of endpoints focused on tasks and perhaps a search endpoint to allow us to filter based on tags, which is our taxonomy. This is usually where I jot down the API I want and figure out if it going to work:
1`[GET] /api/v1/tasks` - Get all Tasks for the authenticated user.2`[POST] /api/v1/tasks` - Create a new Task for the authenticated user.3`[PUT] /api/v1/tasks/{task}` - Update a Task owned by the authenticated user.4`[DELETE] /api/v1/tasks/{task}` - Delete a Task owned by the authenticated user.5 6`[GET] /api/v1/search` - Search for specific tasks or tags.
Now that I understand the routing structure I want to use for my API - I can start implementing Route Registrars. In my last article about Route Registrars, I talked about how to add them to the default Laravel Structure. However, this is not a standard Laravel application, so I have to route things differently. In this application, this is what my Todo
namespace is for. This is what I would classify as system code, which is required for the application to run - but not something the application cares about too much.
After I have added the Trait and Interface required to use Route Registrars, I can start looking to register domains so each one can register its routes. I like to create a Domain Service Provider within the App namespace so that I do not flood my application config with loads of service providers. This provider looks like the following:
1declare(strict_types=1); 2 3namespace App\Providers; 4 5use Domains\Taxonomy\Providers\TaxonomyServiceProvider; 6use Domains\Workflow\Providers\WorkflowServiceProvider; 7use Illuminate\Support\ServiceProvider; 8 9final class DomainServiceProvider extends ServiceProvider10{11 public function register(): void12 {13 $this->app->register(14 provider: WorkflowServiceProvider::class,15 );16 17 $this->app->register(18 provider: TaxonomyServiceProvider::class,19 );20 }21}
Then all I need to do is add this one provider to my config/app.php
, so that I don't have to bust the config cache each time I want to make a change. I made the changes required to the app/Providers/RouteServiceProvider.php
so I can register domain-specific route registrars, which allows me to control routing from my domain, but the application is still in control of loading these.
Let's take a look at the TaskRouteRegistrar
that is under the Workflow domain:
1declare(strict_types=1); 2 3namespace Domains\Workflow\Routing\Registrars; 4 5use App\Http\Controllers\Api\V1\Workflow\Tasks\DeleteController; 6use App\Http\Controllers\Api\V1\Workflow\Tasks\IndexController; 7use App\Http\Controllers\Api\V1\Workflow\Tasks\StoreController; 8use App\Http\Controllers\Api\V1\Workflow\Tasks\UpdateController; 9use Illuminate\Contracts\Routing\Registrar;10use Todo\Routing\Contracts\RouteRegistrar;11 12final class TaskRouteRegistrar implements RouteRegistrar13{14 public function map(Registrar $registrar): void15 {16 $registrar->group(17 attributes: [18 'middleware' => ['api', 'auth:sanctum', 'throttle:6,1',],19 'prefix' => 'api/v1/tasks',20 'as' => 'api:v1:tasks:',21 ],22 routes: static function (Registrar $router): void {23 $router->get(24 '/',25 IndexController::class,26 )->name('index');27 $router->post(28 '/',29 StoreController::class,30 )->name('store');31 $router->put(32 '{task}',33 UpdateController::class,34 )->name('update');35 $router->delete(36 '{task}',37 DeleteController::class,38 )->name('delete');39 },40 );41 }42}
Registering my routes like this allows me to keep things clean and contained with the domain I need them in. My Controllers are still living within the application but separated through a namespace linking back to the domain.
Now that I have some routes I can use, I can start thinking about the actions I want to be able to handle within the tasks domain itself and what Data Objects I might need to use to make sure context is kept in between classes.
Firstly, I will need to create a TaskObject that I can use in the controller to pass through to an action or background job that needs access to the basic properties of a Task but not the entire model itself. I typically keep my data object within the domain, as they are a domain class.
1declare(strict_types=1); 2 3namespace Domains\Workflow\DataObjects; 4 5use Domains\Workflow\Enums\TaskStatus; 6use Illuminate\Support\Carbon; 7use JustSteveKing\DataObjects\Contracts\DataObjectContract; 8 9final class TaskObject implements DataObjectContract10{11 public function __construct(12 public readonly string $name,13 public readonly string $description,14 public readonly TaskStatus $status,15 public readonly null|Carbon $due,16 public readonly null|Carbon $completed,17 ) {}18 19 public function toArray(): array20 {21 return [22 'name' => $this->name,23 'description' => $this->description,24 'status' => $this->status,25 'due_at' => $this->due,26 'completed_at' => $this->completed,27 ];28 }29}
We want to ensure we still keep a level of casting opportunities for the Data Object, as we want it to behave similarly to the Eloquent model. We want to strip the behavior from it to have a clear purpose. Now let's look at how we might use this.
Let's take creating a new task API endpoint as an example here. We want to accept the request and send the processing to a background job so that we have relatively instantaneous responses from our API. The purpose of an API is to speed up the response so that you can chain actions together and create more complicated workflows than you can through the web interface. Firstly we will want to perform some validation on the incoming request, so we will use a FormRequest for this:
1declare(strict_types=1); 2 3namespace App\Http\Requests\Api\V1\Workflow\Tasks; 4 5use Illuminate\Foundation\Http\FormRequest; 6 7final class StoreRequest extends FormRequest 8{ 9 public function authorize(): bool10 {11 return true;12 }13 14 public function rules(): array15 {16 return [17 'name' => [18 'required',19 'string',20 'min:2',21 'max:255',22 ],23 ];24 }25}
We will eventually inject this request into our controller, but before we get to that point - we need to create the action we want to inject into our controller. However, with the way I write Laravel applications, I will need to create an Interface/Contract to use and bind into the container so that I can resolve the action from Laravel DI Container. Let's look at what our Interface/Contract looks like:
1declare(strict_types=1); 2 3namespace Infrastructure\Workflow\Actions; 4 5use App\Models\Task; 6use Illuminate\Database\Eloquent\Model; 7use JustSteveKing\DataObjects\Contracts\DataObjectContract; 8 9interface CreateNewTaskContract10{11 public function handle(DataObjectContract $task, int $user): Task|Model;12}
This controller creates a solid contract for us to follow in our implementation. We want to accept the TaskObject we just designed but also the ID of the user we are creating this task for. We then return a Task Model, or an Eloquent Model, which allows us a little flexibility in our approach. Now let us look at an implementation:
1declare(strict_types=1); 2 3namespace Domains\Workflow\Actions; 4 5use App\Models\Task; 6use Illuminate\Database\Eloquent\Model; 7use Infrastructure\Workflow\Actions\CreateNewTaskContract; 8use JustSteveKing\DataObjects\Contracts\DataObjectContract; 9 10final class CreateNewTask implements CreateNewTaskContract11{12 public function handle(DataObjectContract $task, int $user): Task|Model13 {14 return Task::query()->create(15 attributes: array_merge(16 $task->toArray(),17 ['user_id' => $user],18 ),19 );20 }21}
We use the Task Eloquent Model, open up an instance of the Eloquent Query Builder, and ask it to create a new instance. We then merge the TaskObject as an array and the user ID within an array to create a task in a format Eloquent expects.
Now that we have our implementation, we want to bind this into the container. The way I like to do this is to stay within the Domain so that if we deregister a domain - the container is cleared of any domain-specific bindings that exist. I will create a new Service Provider within my Domain and add the bindings there, then ask my Domain Service Provider to register the additional service provider for me.
1declare(strict_types=1); 2 3namespace Domains\Workflow\Providers; 4 5use Domains\Workflow\Actions\CreateNewTask; 6use Illuminate\Support\ServiceProvider; 7use Infrastructure\Workflow\Actions\CreateNewTaskContract; 8 9final class ActionsServiceProvider extends ServiceProvider10{11 public array $bindings = [12 CreateNewTaskContract::class => CreateNewTask::class,13 ];14}
All we need to do here is bind the interface/contract we created with the implementation and allow the Laravel container to handle the rest. Next, we register this inside our domain service provider for the workflow domain:
1declare(strict_types=1); 2 3namespace Domains\Workflow\Providers; 4 5use Illuminate\Support\ServiceProvider; 6 7final class WorkflowServiceProvider extends ServiceProvider 8{ 9 public function register(): void10 {11 $this->app->register(12 provider: ActionsServiceProvider::class,13 );14 }15}
Finally, we can look at the Store Controller to see how we want to achieve our goal.
1declare(strict_types=1); 2 3namespace App\Http\Controllers\Api\V1\Workflow\Tasks; 4 5use App\Http\Requests\Api\V1\Workflow\Tasks\StoreRequest; 6use Domains\Workflow\DataObjects\TaskObject; 7use Illuminate\Http\JsonResponse; 8use Illuminate\Support\Carbon; 9use Infrastructure\Workflow\Actions\CreateNewTaskContract;10use JustSteveKing\DataObjects\Facades\Hydrator;11use JustSteveKing\StatusCode\Http;12 13final class StoreController14{15 public function __construct(16 private readonly CreateNewTaskContract $action17 ) {}18 19 public function __invoke(StoreRequest $request): JsonResponse20 {21 $task = $this->action->handle(22 task: Hydrator::fill(23 class: TaskObject::class,24 properties: [25 'name' => $request->get('name'),26 'description' => $request->get('description'),27 'status' => strval($request->get('status', 'open')),28 'due' => $request->get('due') ? Carbon::parse(29 time: strval($request->get('due')),30 ) : null,31 'completed' => $request->get('completed') ? Carbon::parse(32 time: strval($request->get('completed')),33 ) : null,34 ],35 ),36 user: intval($request->user()->id),37 );38 39 return new JsonResponse(40 data: $task,41 status: Http::CREATED(),42 );43 }44}
Here we use Laravel DI Container to resolve the action we want to run from the container we just registered, and then we invoke our controller. Using the action, we build the new Task Model by passing in a new instance of TaskObject, which we hydrate using a handy package I created. This uses reflection to make the class based on its properties and a payload. This is an acceptable solution for creating a new task; however, what bugs me is that it is all done synchronously. Let's now refactor this to a background job.
Jobs in Laravel I tend to keep within the main App namespace. The reason for this is because it is something deeply tied into my application itself. However, the logic Jobs can run live within our actions, which live within our domain code. Let's create a new Job:
1php artisan make:job Workflow/Tasks/CreateTask
Then we simply move the logic from the controller to the job. The job, however, wants to accept the Task Object, not the request - so we will need to pass the hydrates object through to this.
1declare(strict_types=1); 2 3namespace App\Jobs\Workflow\Tasks; 4 5use Illuminate\Bus\Queueable; 6use Illuminate\Contracts\Queue\ShouldBeUnique; 7use Illuminate\Contracts\Queue\ShouldQueue; 8use Illuminate\Foundation\Bus\Dispatchable; 9use Illuminate\Queue\InteractsWithQueue;10use Illuminate\Queue\SerializesModels;11use Infrastructure\Workflow\Actions\CreateNewTaskContract;12use JustSteveKing\DataObjects\Contracts\DataObjectContract;13 14final class CreateTask implements ShouldQueue15{16 use Queueable;17 use Dispatchable;18 use SerializesModels;19 use InteractsWithQueue;20 21 public function __construct(22 public readonly DataObjectContract $task,23 public readonly int $user,24 ) {}25 26 public function handle(CreateNewTaskContract $action): void27 {28 $action->handle(29 task: $this->task,30 user: $this->user,31 );32 }33}
Finally, we can refactor our controller to strip out the synchronous action - and in return, we get a quicker response time and jobs that can be retried, which gives us better redundancy.
1declare(strict_types=1); 2 3namespace App\Http\Controllers\Api\V1\Workflow\Tasks; 4 5use App\Http\Requests\Api\V1\Workflow\Tasks\StoreRequest; 6use App\Jobs\Workflow\Tasks\CreateTask; 7use Domains\Workflow\DataObjects\TaskObject; 8use Illuminate\Http\JsonResponse; 9use Illuminate\Support\Carbon;10use JustSteveKing\DataObjects\Facades\Hydrator;11use JustSteveKing\StatusCode\Http;12 13final class StoreController14{15 public function __invoke(StoreRequest $request): JsonResponse16 {17 dispatch(new CreateTask(18 task: Hydrator::fill(19 class: TaskObject::class,20 properties: [21 'name' => $request->get('name'),22 'description' => $request->get('description'),23 'status' => strval($request->get('status', 'open')),24 'due' => $request->get('due') ? Carbon::parse(25 time: strval($request->get('due')),26 ) : null,27 'completed' => $request->get('completed') ? Carbon::parse(28 time: strval($request->get('completed')),29 ) : null,30 ],31 ),32 user: intval($request->user()->id)33 ));34 35 return new JsonResponse(36 data: null,37 status: Http::ACCEPTED(),38 );39 }40}
The whole purpose of my workflow when it comes to Laravel is to create a more reliable, safe, and replicable approach to building my applications. This has allowed me to write code that is not only easy to understand but code that keeps context as it moves through the lifecycle of any business operation.
How do you work with Laravel? Do you do something similar? Let us know your favorite way to work with Laravel code on Twitter!
0 comments:
Post a Comment
Thanks