Skip to content

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.

Diagram

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.

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:

  1. Existing developers spend more time to add functionality across multiple files and headspace switching to ensure operability.
  2. Onboarding developers becomes daunting because it takes time to track down where the logic is interconnected at.
  3. 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.
  4. 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.

Diagram

Solution

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.

Diagram

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}
  • Used by background scheduled jobs to kick off recurring sync job for each entity.
  • Perform a foreach loop to get records from database and dispatch Sync{Entity} for each one.
  • Some models may have their own plural sync, while others may use a parent entity for dispatching sync jobs (ex. SyncDimension instead of SyncAttributes).
Sync{Entity}
  • Dispatched by user or scheduled job to get latest data from API and perform state differential analysis.
  • Users that are in API but missing in database will be imported with Import{Entity}
  • Users that are not in API but are in database will be deprecated and scheduled to expire with Deprecate{Entity} or deactivated with Deactivate{Entity}
  • Users that are in the API and database will be synced for changes to specific fields.

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.)

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.phpApp\Actions\V1\DirectoryAttribute\ListAttributes
../DirectoryAttribute/DescribeAttribute.phpApp\Actions\V1\DirectoryAttribute\DescribeAttribute
../DirectoryAttribute/CreateAttribute.phpApp\Actions\V1\DirectoryAttribute\CreateAttribute
../DirectoryAttribute/UpdateAttribute.phpApp\Actions\V1\DirectoryAttribute\UpdateAttribute
../DirectoryAttribute/ActivateAttribute.phpApp\Actions\V1\DirectoryAttribute\ActivateAttribute
../DirectoryAttribute/DeprecateAttribute.phpApp\Actions\V1\DirectoryAttribute\DeprecateAttribute
../DirectoryAttribute/DeleteExpiredAttribute.phpApp\Actions\V1\DirectoryAttribute\DeleteExpiredAttribute
../DirectoryAttribute/DeactivateAttribute.phpApp\Actions\V1\DirectoryAttribute\DeleteExpiredAttribute
../DirectoryDimension/SyncDimension.php (parent)App\Actions\V1\DirectoryDimension\SyncDimension
../DirectoryAttribute/SyncAttribute.phpApp\Actions\V1\DirectoryAttribute\SyncAttribute
../DirectoryAttribute/ImportAttribute.phpApp\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

use App\Models\Auth\User as AuthUser;
use App\Models\Directory\User as DirectoryUser;
use App\Models\Directory\AttributeUser as AttributeUser; // or as DirectoryAttributeUser

After

use App\Models\AuthUser;
use App\Models\DirectoryUser;
use App\Models\DirectoryAttributeUser;

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().

// Add the dependency injection at the top of your class
use App\Actions\V1\DirectoryAttribute\CreateAttribute;
use App\Enums\DirectoryAttributeType;
use App\Models\DirectoryDimension;
// Use the action inside your method (likely another handle() method)
// Get the parent dimension record
$dimension = DirectoryDimension::where('slug', 'dept')->firstOrFail();
// Create a new record with the action
$attribute = CreateAttribute::make()->handle(
dimension: $dimension,
type: DirectoryAttributeType::RULESET,
name: 'Small Business Sales',
slug: 'smb-sales'
);

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.

// Good
$attribute = CreateAttribute::make()->handle(
dimension: $dimension,
type: DirectoryAttributeType::RULESET,
name: 'Small Business Sales',
slug: 'smb-sales'
);
// Not Good
$attribute = CreateAttribute::make()->handle(
$dimension,
DirectoryAttributeType::RULESET,
'Small Business Sales',
'smb-sales'
);
// Not Good
$attribute = CreateAttribute::make()->handle(dimension: $dimension, type: DirectoryAttributeType::RULESET, name: 'Small Business Sales', slug: 'smb-sales');
// Not Good
$attribute = CreateAttribute::make()->handle($dimension, DirectoryAttributeType::RULESET, 'Small Business Sales', 'smb-sales');
// Good
ActivateAttribute::make()->handle(attribute: $attribute);
// Also Good
ActivateAttribute::make()->handle(
attribute: $attribute
);
// Not Good
ActivateAttribute::make()->handle($attribute);

Type Casting

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 using Carbon\Carbon::parse($value) or now()->...
  • {Enum} from app/Enums/
  • {Model} from app/Models/
namespace App\Actions\V1\DirectoryAttribute\CreateAttribute;
use App\Models\DirectoryDimension;
use App\Enums\DirectoryAttributeType;
class CreateAttribute
{
public function handle(
DirectoryDimension $dimension,
DirectoryAttributeType $type,
string $name,
?string $slug = null,
?int $expires_after_days = null,
): DirectoryAttribute {
// Action logic is truncated for example
}
}

This demonstrates why using named arguments is important in order to improve readability when using type casting.

$attribute = CreateAttribute::make()->handle(
dimension: $dimension,
type: DirectoryAttributeType::RULESET,
name: 'Small Business Sales',
slug: 'smb-sales'
);

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.

// Preferred Format
$attributes = DirectoryDimension::query()
->where('slug', 'dept')
->directoryAttributes()
->each(function($attribute) {
DeactivateAttribute::dispatch(attribute: $attribute)->onQueue('directory');
});
// Identical Result (more familiar syntax)
foreach($dimension->directoryAttributes() as $attribute) {
DeactivateAttribute::dispatch(attribute: $attribute)->onQueue('directory');
}

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.

namespace App\Enums;
enum DirectoryAttributeType: string
{
case SOURCE = 'source';
case RULESET = 'ruleset';
case CATCH = 'catch';
case MANUAL = 'manual';
}

The most common one that youโ€™ll see is DirectoryEntityState.

namespace App\Enums;
enum DirectoryEntityState: string
{
case STAGED = 'staged';
case ACTIVE = 'active';
case EXPIRING = 'expiring';
case EXPIRED = 'expired';
case DEACTIVATED = 'deactivated';
}

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.

$state = DirectoryEntityState::ACTIVE;
$state = DirectoryEntityState::ACTIVE->value;
if(DirectoryEntityState::ACTIVE == $state) {
//
}
if(DirectoryEntityState::ACTIVE === DirectoryEntityState::from($state)) {
//
}

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.

// Truncated from example above
// Create a new record with the action
$attribute = CreateAttribute::make()->handle(
dimension: $dimension,
type: DirectoryAttributeType::RULESET,
name: 'Small Business Sales',
slug: 'smb-sales'
);
// Sync attribute users
SyncAttribute::dispatch(
attribute: $attribute
)->onQueue('directory');

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 new vendor 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:

Terminal window
php artisan queue:work --queue=auth,default,directory,emails,manifest,notifications

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.

routes/api.php
use Illuminate\Support\Facades\Route;
Route::middleware(['api', 'auth:sanctum', 'throttle:api-200-1'])->prefix('v1')->group(function () {
Route::prefix('directory')->group(function () {
Route::prefix('attributes')->group(function () {
$can_prefix = 'directory.attribute';
Route::get('/', \App\Actions\V1\DirectoryAttribute\ListAttributes::class)->can($can_prefix . '.view');
Route::post('/', \App\Actions\V1\DirectoryAttribute\CreateAttribute::class)->can($can_prefix . '.create');
Route::get('/{attribute}', \App\Actions\V1\DirectoryAttribute\DescribeAttribute::class)->can($can_prefix . '.view');
Route::patch('/{attribute}', \App\Actions\V1\DirectoryAttribute\UpdateAttribute::class)->can($can_prefix . '.update');
Route::post('/{attribute}/activate', \App\Actions\V1\DirectoryAttribute\ActivateAttribute::class)->can($can_prefix . '.activate');
Route::delete('/{attribute}/deprecate', \App\Actions\V1\DirectoryAttribute\DeprecateAttribute::class)->can($can_prefix . '.deactivate');
Route::delete('/{attribute}/deactivate', \App\Actions\V1\DirectoryAttribute\DeactivateAttribute::class)->can($can_prefix . '.deactivate');
Route::delete('/{attribute}/sync', \App\Actions\V1\DirectoryAttribute\SyncAttribute::class)->can($can_prefix . '.sync');
});
});
});

Validation

Controller Validation Rules

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').

use App\Enums\DirectoryAttributeType;
use App\Http\Resources\DirectoryAttributeResource;
use Illuminate\Validation\Rule;
use Lorisleiva\Actions\ActionRequest;
class CreateAttribute
{
use AsAction;
public function rules(): array
{
return [
'dimension_id' => [
'required',
'ulid',
'exists:App\Models\DirectoryDimension,id',
],
'type' => [
'required',
Rule::enum(DirectoryAttributeType::class)
],
'name' => [
'required',
'string',
'max:63',
],
'slug' => [
'sometimes',
'alpha_dash',
'max:55',
],
'state' => [
'sometimes',
'required',
'in:active,staged'
],
'expires_after_days' => [
'sometimes',
'nullable',
'numeric',
'between:0,1095'
],
];
}
public function asController(ActionRequest $request)
{
$dimension = DirectoryDimension::findOrFail($request->validated('dimension_id'));
$attribute = $this->handle(
dimension: $dimension,
type: DirectoryAttributeType::from($request->validated('type')),
name: $request->validated('name'),
slug: $request->validated('slug'),
state: $request->validated('state', 'active'),
expires_after_days: $request->validated('expires_after_days'),
);
return (new DirectoryAttributeResource($attribute))->response()->setStatusCode(201);
}
public function handle(
DirectoryDimension $dimension,
DirectoryAttributeType $type,
string $name,
?string $slug = null,
string $state = DirectoryEntityState::ACTIVE->value,
?int $expires_after_days = null,
?DirectorySource $source = null,
): DirectoryAttribute {
$event_ms = now();
$attribute = DirectoryAttribute::create([
'source_id' => $source?->id,
'dimension_id' => $dimension->id,
'type' => $type,
'name' => Str::headline($name),
'slug' => $slug ?? CalculateAbbreviation::make()->handle($name),
'state' => $state,
'expires_after_days' => $expires_after_days,
]);
Log::create(
event_ms: $event_ms,
event_type: 'directory.attribute.create.success',
level: 'info',
log: true,
message: 'Directory Custom Attribute Created',
metadata: [
'state' => $state,
],
method: __METHOD__ . ':' . __LINE__,
parent_id: $dimension->id,
parent_type: DirectoryDimension::class,
parent_reference_key: 'slug',
parent_reference_value: $dimension->slug,
record_id: $attribute->id,
record_type: DirectoryAttribute::class,
record_reference_key: 'slug',
record_reference_value: $attribute->slug,
transaction: true
);
return $attribute;
}
}

Additional 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:

$validator->errors()->add('my_field', 'Error message goes here');

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.

namespace App\Actions\V1\DirectoryAttribute;
use App\Http\Resources\DirectoryAttributeResource;
use App\Models\DirectoryAttribute;
use Illuminate\Validation\Validator;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class CreateAttribute
{
use AsAction;
public function rules(): array
{
return [
'dimension_id' => [
'required',
'ulid',
'exists:App\Models\DirectoryDimension,id',
],
'slug' => [
'sometimes',
'alpha_dash',
'max:55',
],
// truncated for example readability
];
}
public function afterValidator(Validator $validator, ActionRequest $request): void
{
$existing_record = DirectoryAttribute::query()
->where('dimension_id', $request->validated('dimension_id'))
->whereBlind('slug', 'slug_index', $request->validated('slug'))
->exists();
if ($existing_record) {
$validator->errors()->add('slug', 'Attribute slug already exists for this dimension.');
}
}
public function asController(ActionRequest $request)
{
$dimension = $this->handle(
name: $request->validated('name'),
slug: $request->validated('slug'),
expires_after_days: $request->validated('expires_after_days'),
);
return (new DirectoryAttributeResource($dimension))->response()->setStatusCode(201);
}

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.

namespace App\Actions\V1\DirectoryDimension;
use App\Enums\DirectoryEntityState;
use App\Models\DirectoryDimension;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
use Illuminate\Validation\Validator;
class ActivateDimension
{
use AsAction;
public function afterValidator(Validator $validator, ActionRequest $request): void
{
$dimension = DirectoryDimension::withTrashed()->findOrFail($request->route()->parameter('dimension'));
switch ($dimension->state) {
case DirectoryEntityState::ACTIVE->value:
$validator->errors()->add('state', 'The record is already active.');
break;
case DirectoryEntityState::EXPIRED->value:
$validator->errors()->add('state', 'The record has expired and has been soft deleted. Use restore instead.');
break;
case DirectoryEntityState::DEACTIVATED->value:
$validator->errors()->add('state', 'The record has been deactivated and soft deleted. Use restore instead.');
break;
default:
break;
}
}
}

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.

<?php
namespace App\Actions\V1\DirectoryDimension;
use App\Actions\V1\DirectoryAttribute\ActivateAttribute;
use App\Actions\V1\DirectoryRuleset\ActivateRuleset;
use App\Enums\DirectoryEntityState;
use App\Exceptions\Actions\ValidationException;
use App\Http\Resources\DirectoryDimensionResource;
use App\Models\DirectoryDimension;
use App\Models\DirectorySource;
use Illuminate\Validation\Validator;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
use Provisionesta\Audit\Log;
class ActivateDimension
{
use AsAction;
public function afterValidator(Validator $validator, ActionRequest $request): void
{
$dimension = DirectoryDimension::withTrashed()->where('id', $request->validated('dimension'))->first();
switch ($dimension->state) {
case DirectoryEntityState::ACTIVE->value:
$validator->errors()->add('state', 'The record is already active.');
break;
case DirectoryEntityState::EXPIRED->value:
$validator->errors()->add('state', 'The record has expired and has been soft deleted. Use restore instead.');
break;
case DirectoryEntityState::DEACTIVATED->value:
$validator->errors()->add('state', 'The record has been deactivated and soft deleted. Use restore instead.');
break;
default:
break;
}
}
public function asController(DirectoryDimension $dimension, ActionRequest $request)
{
$this->handle(dimension: $dimension);
return (new DirectoryDimensionResource($dimension))->response()->setStatusCode(202);
}
public function handle(DirectoryDimension $dimension): DirectoryDimension
{
$event_ms = now();
$this->validateState(dimension: $dimension);
$this->activateStagedRelationships(dimension: $dimension);
$this->activateExpiringRelationships(dimension: $dimension);
$dimension->expires_at = null;
$dimension->state = DirectoryEntityState::ACTIVE;
$dimension->save();
Log::create(
event_ms: $event_ms,
event_type: 'directory.dimension.activate.success',
level: 'info',
log: true,
message: 'Directory Dimension Activated',
metadata: [],
method: __METHOD__ . ':' . __LINE__,
parent_id: $dimension->source_id,
parent_type: $dimension->source_id ? DirectorySource::class : null,
record_id: $dimension->id,
record_type: DirectoryDimension::class,
record_reference_key: 'attribute_key',
record_reference_value: $dimension->attribute_key,
transaction: true
);
$dimension = $dimension->fresh();
return $dimension;
}
private function validateState(DirectoryDimension $dimension): void
{
switch ($dimension->state) {
case DirectoryEntityState::ACTIVE->value:
throw new ValidationException('The record is already active.');
case DirectoryEntityState::EXPIRED->value:
throw new ValidationException('The record has expired and has been soft deleted. Use restore instead.');
case DirectoryEntityState::DEACTIVATED->value:
throw new ValidationException('The record has been deactivated and soft deleted. Use restore instead.');
default:
// Continue with activation process
}
}
private function activateStagedRelationships(DirectoryDimension $dimension): void
{
if ($dimension->state === DirectoryEntityState::STAGED->value) {
$dimension->directoryAttributes()
->where('state', DirectoryEntityState::STAGED->value)
->each(function ($attribute) {
ActivateAttribute::dispatch(attribute: $attribute)->onQueue('directory');
});
$dimension->directoryRulesets()
->where('state', DirectoryEntityState::STAGED->value)
->each(function ($ruleset) {
ActivateRuleset::dispatch(ruleset: $ruleset)->onQueue('directory');
});
}
}
private function activateExpiringRelationships(DirectoryDimension $dimension): void
{
if ($dimension->state === DirectoryEntityState::EXPIRING->value) {
$dimension->directoryAttributes()
->where('state', DirectoryEntityState::EXPIRING->value)
->where('expires_at', $dimension->expires_at)
->each(function ($attribute) {
ActivateAttribute::dispatch(attribute: $attribute)->onQueue('directory');
});
$dimension->directoryRulesets()
->where('state', DirectoryEntityState::EXPIRING->value)
->where('expires_at', $dimension->expires_at)
->each(function ($ruleset) {
ActivateRuleset::dispatch(ruleset: $ruleset)->onQueue('directory');
});
}
}
}

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.

class ActivateDimension
{
// Truncated for example
public function handle(DirectoryDimension $dimension): DirectoryDimension
{
$event_ms = now();
$this->validateState(dimension: $dimension);
$this->activateStagedRelationships(dimension: $dimension);
$this->activateExpiringRelationships(dimension: $dimension);
$dimension->expires_at = null;
$dimension->state = DirectoryEntityState::ACTIVE;
$dimension->save();
Log::create(
event_ms: $event_ms,
event_type: 'directory.dimension.activate.success',
level: 'info',
log: true,
message: 'Directory Dimension Activated',
metadata: [],
method: __METHOD__ . ':' . __LINE__,
parent_id: $dimension->source_id,
parent_type: $dimension->source_id ? DirectorySource::class : null,
record_id: $dimension->id,
record_type: DirectoryDimension::class,
record_reference_key: 'attribute_key',
record_reference_value: $dimension->attribute_key,
transaction: true
);
$dimension = $dimension->fresh();
return $dimension;
}
private function validateState(DirectoryDimension $dimension): void
{
switch ($dimension->state) {
case DirectoryEntityState::ACTIVE->value:
throw new ValidationException('The record is already active.');
case DirectoryEntityState::EXPIRED->value:
throw new ValidationException('The record has expired and has been soft deleted. Use restore instead.');
case DirectoryEntityState::DEACTIVATED->value:
throw new ValidationException('The record has been deactivated and soft deleted. Use restore instead.');
default:
// Continue with activation process
}
}
private function activateStagedRelationships(DirectoryDimension $dimension): void
{
if ($dimension->state === DirectoryEntityState::STAGED->value) {
$dimension->directoryAttributes()
->where('state', DirectoryEntityState::STAGED->value)
->each(function ($attribute) {
ActivateAttribute::dispatch(attribute: $attribute)->onQueue('directory');
});
$dimension->directoryRulesets()
->where('state', DirectoryEntityState::STAGED->value)
->each(function ($ruleset) {
ActivateRuleset::dispatch(ruleset: $ruleset)->onQueue('directory');
});
}
}
private function activateExpiringRelationships(DirectoryDimension $dimension): void
{
if ($dimension->state === DirectoryEntityState::EXPIRING->value) {
$dimension->directoryAttributes()
->where('state', DirectoryEntityState::EXPIRING->value)
->where('expires_at', $dimension->expires_at)
->each(function ($attribute) {
ActivateAttribute::dispatch(attribute: $attribute)->onQueue('directory');
});
$dimension->directoryRulesets()
->where('state', DirectoryEntityState::EXPIRING->value)
->where('expires_at', $dimension->expires_at)
->each(function ($ruleset) {
ActivateRuleset::dispatch(ruleset: $ruleset)->onQueue('directory');
});
}
}
}

In the example below, look at the checkIfAlreadyActivated() method.

namespace App\Actions\V1\DirectoryAttributeUser;
use App\Enums\DirectoryEntityState;
use App\Exceptions\Actions\ValidationException;
use App\Http\Resources\DirectoryAttributeUserResource;
use App\Models\DirectoryAttribute;
use App\Models\DirectoryAttributeUser;
use Illuminate\Validation\Validator;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
use Provisionesta\Audit\Log;
class ActivateAttributeUser
{
use AsAction;
public function asController(DirectoryAttributeUser $attribute_user, ActionRequest $request)
{
$attribute_user = $this->handle(attribute_user: $attribute_user);
return (new DirectoryAttributeUserResource($attribute_user))->response()->setStatusCode(202);
}
public function handle(DirectoryAttributeUser $attribute_user): DirectoryAttributeUser
{
$event_ms = now();
$this->checkIfAlreadyActivated(attribute_user: $attribute_user);
$attribute_user->state = DirectoryEntityState::ACTIVE->value;
$attribute_user->deleted_at = null;
$attribute_user->save();
Log::create(
event_ms: $event_ms,
event_type: 'directory.attribute_user.activate.success',
level: 'info',
log: true,
message: 'Directory Attribute User Activated',
metadata: [],
method: __METHOD__ . ':' . __LINE__,
parent_id: $attribute_user->attribute_id,
parent_type: DirectoryAttribute::class,
parent_reference_key: 'slug',
parent_reference_value: $$attribute_user->directoryAttribute->slug,
record_id: $attribute_user->id,
record_type: DirectoryAttributeUser::class,
transaction: true
// TODO Add user IDs
);
$attribute_user = $attribute_user->fresh();
return $attribute_user;
}
private function checkIfAlreadyActivated(DirectoryAttributeUser $attribute_user): void
{
if ($attribute_user->state == DirectoryEntityState::ACTIVE->value || DirectoryEntityState::EXPIRING->value) {
Log::create(
event_type: 'directory.attribute_user.activate.validation.error',
level: 'info',
log: true,
message: 'Directory Attribute User is Already Activated',
metadata: [],
method: __METHOD__ . ':' . __LINE__,
parent_id: $attribute_user->attribute_id,
parent_type: DirectoryAttribute::class,
parent_reference_key: 'slug',
parent_reference_value: $$attribute_user->directoryAttribute->slug,
record_id: $attribute_user->id,
record_type: DirectoryAttributeUser::class,
transaction: true
// TODO Add user IDs
);
throw new ValidationException(
code: 'directory.attribute_user.activate.validation.error',
message: 'The attribute user is already active or expiring.'
);
}
}
}