Actions
Laravel Actions Overview
Background Context
Letโs have a look at an oversimplified diagram that shows how the components of model-view-controller (MVC) applications connect to each other.
If you use the Laravel framework out of the box, you are going to need several different files (classes) to handle the logic for each API endpoint. This is pretty standard across model-view-controller (MVC) frameworks and isnโt specific to Laravel.
- ๐ Route
- ๐ Controller
- ๐ Form Request
- ๐ Model
- ๐ Background Queue Jobs
- ๐ HTTP Resources for JSON Responses
- Business Logic Classes (Actions, Repository, or Service Pattern) - No standardized docs
Problem Statement
When building large scale applications in a domain-driven development (DDD) model, you end up with files all over the place and the maintainability of the code becomes a challenge for several reasons:
- Existing developers spend more time to add functionality across multiple files and headspace switching to ensure operability.
- Onboarding developers becomes daunting because it takes time to track down where the logic is interconnected at.
- It takes longer and introduces risk with merge/pull requests because youโre not sure if the logic will break something somewhere else since itโs not self contained.
- Implementing CODEOWNERS or sensitive business logic is nearly impossible.
When you look at the Directory
namespace, we have models for Sources
, Abbreviations
, Users
, Identities
, Dimensions
, Blueprints
, Attributes
, Attribute Users
, Rulesets
, Rules
, Conditions
, and Ruleset Users
.
For each of these 12 models, we have API endpoints and/or background job actions for List
, Describe
, Create
, Update
, Import
, Sync
, Activate
, Deprecate
, Deactivate
, and Delete Expired
records. That is 10 separate sets of code logic for handling these functions, resulting in approximately 120 things to maintain.
With 7+ separate files that each of these endpoints/actions need to be defined at, thatโs 840 places to maintain code at just for this namespace of business logic. Thatโs daunting.
Under the Hood
Letโs look at how these concepts are used in a real world example.
Solution
- ๐ Laravel Actions Package Docs
- ๐ Package Author - Why I Wrote Laravel Actions
- ๐ Refactoring Controllers - If We Didnโt Use Actions
You may have heard of the service class pattern, repository pattern, or action pattern. We are using the action pattern.
The Laravel Actions package provides a streamlined approach of consolidating the logic that was spread out across multiple classes into methods (functions) within the same action class file. This greatly improves the ability to organization domain logic and adheres much closer to the single responsibility principle (SRP), and allows for assigning specific CODEOWNERS to specific all encapsulating business logic.
Although this documentation will help you get started, the best way to learn is to study existing actions that have been created. You should rarely need to create an action from scratch and you should usually be able to copy/fork an existing action and adapt it to your needs.
Types of Actions
Each action can be used externally as API endpoints, internally by controllers, and can be invoked (run) or dispatched (background job) from other actions or anywhere in the application.
The actions for each entity may vary, however the common ones are listed below.
Action Class Name | Purpose |
---|---|
List{Entities} |
Get list of records. API requests use spatie/laravel-query-builder |
Describe{Entity} |
Get details about a specific record. Comparable to get , info , and show terminology. API requests use spatie/laravel-query-builder for including relationships. |
Create{Entity} |
All entities are created in a staged state. The Activate{Entity} action must be called to activate it. |
Import{Identity} |
Import is identical to create and is used by background sync jobs when fetching data from the vendor API. Imported records are automaticaly activated and are not created in a staged state. Create is used by human users while Import is used by dispatched sync jobs. |
Update{Entity} |
Only metadata can be changed. All state changes are handled through separate actions. |
Activate{Entity} |
Changes state to active from staged or expiring . If already expiring , sets expires_at to null.
|
Deprecate{Entity} |
Changes state to expiring and sets the expires_at date |
DeletedExpired{Entity} |
Dispatched by sync job if expires_at is in the past to set expired state and soft delete the record. Any child records will be deprecated or deactivated. |
Deactivate{Entity} |
Immediately soft delete the record without expiration. Any child records will be deprecated or deactivated. |
Sync{Entities} |
|
Sync{Entity} |
|
Domain Logic File Schema
Each action can be found in app/Actions/V1/{Namespace}{Entity}/{Verb}{Entity}.php
and when used with dependency injection is App\Actions\V1\{Namespace}{Entity}\{Verb}{Entity}
.
For example, the action for creating a Directory Attribute is located in app/Actions/V1/DirectoryAttribute/CreateAttribute.php
.
Versioning
The V1
namespace represents a combination of the API version (ex. https://accessctl.test/api/v1/{namespace}/{entities}
) as well as a architectural version (ex. v1.x
vs v2.x
). We use semantic versioning with a โdesign it right first, donโt wait to fix it laterโ approach. By the time that we get to a new architectural version, we will still need to provide support for legacy deployments since IT environments have a long shelf life and this allows us to provide separation of concerns.
Namespaces
We have different functionality that operates mostly independently within each namespace. This is not to be confused with a dependency injection namespace, and is a โdomain logicโ namespace.
Auth
Directory
{VendorName}
- Examples:
Aws
,Gitlab
,GoogleCloud
,GoogleWorkspace
,Okta
,Slack
, etc.)
- Examples:
Entity
An entity is just an unambiguous term for a โnameโ. The term comes from database terminology.
Within the Directory
namespace, we have entities for Source
, User
, Identity
, Dimension
, Blueprint
, Attribute
, Ruleset
, Rule
, and Condition
.
We also define an entity for many-to-many relationships by merging the parent and child name together. For example, BlueprintDimension
and AttributeUser
and RulesetUser
. The parent is not determined by which belongs to the other, it is determined by if the parent was deleted, all child records should be deleted. For this reason, we have determined that a User
doesnโt own anything, they are attached to other entities so will always be a child. This helps clarify in the Auth
namespace whether it should be UserRole
or RoleUser
. The user is a member of a role, they donโt own the role.
Whenever using the entity in a class name, it should be singular (not plural) unless it is for a List{Entities}
or Sync{Entities}
or similar that handles multiple records. When an entity is used in a URL, it is usually plural. When an entity is referred to in an output (Ex. JSON resource), it is singular if it is a parent relationship and plural if it refers to child relationships.
Actions
There will be corresponding actions for each model in app/Actions/V1/{Namespace}{Entity}/{Verb}{Entity}.php
.
Here is an example of the DirectoryAttribute
model:
File Path (app/Actions/V1/* ) | Fully-Qualified Dependency Injection Class Name |
---|---|
../DirectoryAttribute/ListAttributes.php | App\Actions\V1\DirectoryAttribute\ListAttributes |
../DirectoryAttribute/DescribeAttribute.php | App\Actions\V1\DirectoryAttribute\DescribeAttribute |
../DirectoryAttribute/CreateAttribute.php | App\Actions\V1\DirectoryAttribute\CreateAttribute |
../DirectoryAttribute/UpdateAttribute.php | App\Actions\V1\DirectoryAttribute\UpdateAttribute |
../DirectoryAttribute/ActivateAttribute.php | App\Actions\V1\DirectoryAttribute\ActivateAttribute |
../DirectoryAttribute/DeprecateAttribute.php | App\Actions\V1\DirectoryAttribute\DeprecateAttribute |
../DirectoryAttribute/DeleteExpiredAttribute.php | App\Actions\V1\DirectoryAttribute\DeleteExpiredAttribute |
../DirectoryAttribute/DeactivateAttribute.php | App\Actions\V1\DirectoryAttribute\DeleteExpiredAttribute |
../DirectoryDimension/SyncDimension.php (parent) | App\Actions\V1\DirectoryDimension\SyncDimension |
../DirectoryAttribute/SyncAttribute.php | App\Actions\V1\DirectoryAttribute\SyncAttribute |
../DirectoryAttribute/ImportAttribute.php | App\Actions\V1\DirectoryAttribute\ImportAttribute |
Models
For every database table, there will be an associated model in app/Models/{Namespace}{Entity}.php
.
A model specifies details about a database table that has {namespace}_{entities}
syntax, the fields in the table, any type casting and encryption configuration, and itโs relationships to other models. The backend of models is the Object Relationship Mapping (ORM) engine that handles the creation, update, deletion, and SQL relationships in an intuitive way for developers.
All models are prefixed with the namespace that they belong to (ex. DirectoryUser
). Based on past experience, we do not like using short model names (ex. User
) even if they use class dependency injection namespaces since it is ambiguous and when used at the same time with different types of users (ex. AuthUser
and DirectoryUser
and DirectoryAttributeUser
).
As a result, the dependency injection in each file becomes messy and inconsistent with implementation. This namespace has allowed us to flatten the directory schema so that all models regardless of namespace are in App/Models/{Namespace}{Entity}
.
Before
After
Using Existing Actions
If youโre familiar with single method invokable classes or controllers, an action is very similar.
Run an Action
There are two documented ways to run (invoke) an action, using the run()
method or the make()->handle()
method. The autocompletion in your IDE only works with make()->handle()
since run()
is an abstraction layer that does not have named arguments. We do not use run()
in our code base and have standardized on make()->handle()
.
Named Arguments and Parameters
We use named arguments instead of positional arguments exclusively throughout our code base. This allows us a lot more flexibility as the code base evolves if we add or change arguments and improves readability significantly. Your IDE should provide autocompletion or documentation of each named argument when you start to type inside the method arguments.
For readability, we allow up to two named arguments in a single line. If more than two named arguments are used, then use an indented array style.
If you are creating new actions, named arguments should use snake_case
syntax. Do not use camelCase
or kebab-case
.
Type Casting
- ๐ PHP - Types
- ๐ PHP - Enums
- ๐ Carbon Dates
We use a variety of types when defining named arguments. We try to avoid string values for database records and use instantiated models instead. We will also use Enums where feasible. In the vast majority of cases, we will use one of the following types:
string
?string
(string that accepts a null value)int
?int
(int that accepts a null value)Carbon
pre-instantiated date usingCarbon\Carbon::parse($value)
ornow()->...
{Enum}
fromapp/Enums/
{Model}
fromapp/Models/
This demonstrates why using named arguments is important in order to improve readability when using type casting.
Model Binding and Types
You saw in the previous example how we used the DirectoryDimension
model to set the $dimension
variable.
This works well when getting a parent relationship.
To get child relationships, you can use Laravel Model Relationships in combination with collections where needed.
See the App\Actions\V1\DirectoryDimension\DeprecateDimension::deprecateRelationships()
method for a comprehensive example.
You can see more examples in the Sync{Entity}
, Deprecate{Entity}
, and Deactivate{Entity}
actions.
Enums
We use backed Enums instead of strings if there are a small handful of allowed values.
The most common one that youโll see is DirectoryEntityState
.
Using Enums in Laravel can be a bit tricky with whether or not you just need to define the enum case, or whether you need to get the value for the case. Until we have a better best practice recommendation, please be careful and check the returned value during development (using dd()
) when adding Enums to the code.
Dispatch an Action
We use Redis queue jobs to handle business logic asynchronously. This is useful in foreach loops or non time sensitive requests where we do not need the result in order to respond to the user.
The dispatch()
method will use the handle()
methodโs logic. It is documented that we can use asJob()
, however we rarely do this in our code base.
Queue Names
We define queue names based on the namespace that the logic lives in, or create a dedicated queue for high volume transactions.
auth
directory
emails
manifest
notifications
TODO: We do not know yet whether we will handle vendor background jobs using the
manifest
queue, a newvendor
queue, or a separate queue for each vendor (ex.okta
).
When running ./bin/develop.sh
during local development, the instructions in the output will have you start your local queue workers in your Terminal using this command:
Routing for API Requests
Although Laravel Actions does support including route definitions inside of the action, we lose some of the benefits of route model binding and route caching. Weโve seen additional commentary on GitHub issues from Loris Leiva (creator of Laravel Actions) about the technical limitations and we have decided to stick with the native Laravel routing in routes/api.php
and routes/web.php
.
To avoid having to include the dependency injection and fill up the use
statements with every action class, we use fully qualified paths inside of each line for the route.
See the authorization page to learn more about how we use the can()
middleware for permissions.
Validation
Controller Validation Rules
- ๐ Laravel Framework - Form Request Validation
- ๐ Laravel Framework - Available Validation Rules
- ๐ Laravel Actions - Add Validation to Controllers
The rules()
method replaces the need for a separate file for the Form Request class. The logic is applied based on the request received in the asController()
method.
The validated data can be accessed in the controller using $request->validated('key_name')
.
Additional Validation Logic
- ๐ Laravel Framework - Complex Conditional Validation
- ๐ Laravel Actions - Custom Validation Logic
The rules()
method is designed for validating input data syntax. If you want to perform additional validation logic, you can use the afterValidator()
method.
The request validation behind the scenes check if anything has been added to $validator->errors()
. If nothing has been added, then the request proceeds into the asController()
method.
To throw a validation error and return a 422 response with error message(s) just like you would with the rules()
array, you can use the following syntax:
The my_field
can match an existing column or be something custom. If this handles a UI form request, it should match the input field name. If this is for an API request, it will appear in the JSON response regardless of what you use.
Example with Unique Encrypted Columns
The syntax validation rules will perform a SQL query to see if a column contains a value. We use field level encryption in many places, so any results would be an encrypted value and not match the user provided input. You can use the afterValidator()
method for handling blind index searches using whereBlind()
searches.
Example with Custom Enum Logic
You can use Rule::enum()
in the rules()
array to check if a valid value is provided. You can use afterValidator()
to check if that value is one of the expected values given the current values of a record.
Validating Internal Actions
Validation only applies to controller requests from users. If you invoke the action internally with the handle()
method, no validation occurs unless you have added it in the handle method.
Handle Method
The handle()
method can contain any logic that you need to perform, including getting records from the database, parsing and using collections, adding log entries, dispatching other actions, making external API calls, etc.
It is a best practice to copy logic from an existing related action instead of trying to invent something from scratch. This is the easiest way for us to achieve efficiencies of scale since we can optimize across multiple actions if we see a pattern and will create Traits or other centralized helpers.
Private Methods
If your handle()
method has a lot of logic (ex. more than 50 lines) with easy to compartmentalize sections or extensive if/else or switch logic, it is recommended to move each compartment of logic into a new private method in the action.
You can see how the activateStagedRelationships()
and activateExpiringRelationships()
methods are used from the previous example when activating a dimension.
In the example below, look at the checkIfAlreadyActivated()
method.