NOTE: Some recent, major refactoring has made some sections of this document inaccurate. It will be updated soon.
I am a PHP developer that has done a lot of Ruby on Rails development over the last few years. There are a few gems I found especially useful again and again.
- drapergem/draper for decorating model instances
- collectiveidea/interactor for small service objects
- elabs/pundit for authorizing a user against a resource
I decided to author and publish a series of small, tested PHP packages that attempt to borrow bits of functionality from these gems.
- deefour/presenter for decorating model instances
- deefour/interactor for small service objects
- deefour/authorizer for authorizing a user against a resource
- deefour/transformer for converting raw input data to immutable, consistently formatted DTOs
Note: If a method or class appears in an example below without sufficient mention or explanation, refer to the
README.md
in the above URLs. Full documentation for the packages is provided on their respective repositories.
This document describes how I use the last three packages above to handle the common scenario of editing an existing model, a Podcast
, within a Laravel 5.x application.
A very high-level overview of the role each package plays when handling a request.
- Authorization for a request is performed by
Deefour\Authorizer
by way of a "Policy" for the resource being manipulated - An "Interactor" holds the business logic to manipulate the resource
- The attributes of the raw input data found on the request are converted into an expected, consistent format through a "Transformer"
- The interactor expects a specific "Context" to be passed to it; a class containing all the information it needs to perform it's unit of work.
- A context object is commonly composed of at least one transformer.
Note: Some code or best practices are omitted or bypassed in the code blocks below in favor of brevity and clarity.
<?php namespace App;
use Deefour\Authorizer\Contracts\Authorizable as AuthorizableContract;
use Deefour\Authorizer\ResolvesPoliciesAndScopes;
use Illuminate\Database\Eloquent\Model;
class Podcast extends Model implements AuthorizableContract {
use ResolvesPoliciesAndScopes;
/**
* {@inheritdoc}
*/
public function policyNamespace() {
return 'App\Policies';
}
}
<?php namespace App;
use Deefour\Authorizer\Contracts\Authorizee as AuthorizeeContract;
class User extends Model implements AuthorizeeContract {
}
In order for the application to check if a user is authorized to perform an action on the resource, the model representing the user must be marked as the "Authorizee" and the resource being authorized must be marked as "Authorizable". The policyNamespace()
method override tells the application where to look when attempting to automatically resolve a "Policy" class based on a model's FQN for authorization.
Note: In a real application, much of the use of the
Deefour\...
namespaced traits and related configuration would be pushed up into a generic parent model class to make policy class resolution available and marking all models as authorizable.
<?php namespace App\Policies;
use Deefour\Authorizer\Policy;
class PodcastPolicy extends Policy {
/**
* Only the owner of the podcast can modify the record.
*
* @return boolean
*/
public function update() {
return $this->record->user_id === $this->user->id;
}
}
With the models and policy class shown above, authorization can already be performed.
<?php
use Deefour\Authorizer\Authorizer;
use App\User;
use App\Podcast;
$user = User::find(12);
$podcast = Podcast::find(34);
$policy = Authorizer::policy($user, $podcast);
$policy->update(); //=> boolean
The owner of the podcast is logged in and changed a few things on it's edit form. The form was just submitted, sending a PUT
request that will be handled by the PostController@update
method in a Laravel 5.x application. The request data looks as follows:
id=34
title=Daniel's Digest - Episode 192
tags=life, productivity new-car, personal-finance
If we request all of the above data from an Illuminte\Http\Request
instance within a controller action, the following array would be returned
[
'id' => '34',
'title' => 'Daniel's Digest - Episode 192',
'tags' => 'life, productivity new-car, personal-finance'
]
We should be able to expect data in the following format though.
[
'id' => 34,
'title' => 'Daniel's Digest - Episode 192',
'tags' => [ 'life', 'productivity', 'new-car', 'personal-finance' ]
]
Let's see how that is done.
<?php namespace App\Transformers;
use Deefour\Transformer\Transformer;
class PodcastTransformer extends Transformer {
/**
* {@inheritdoc}
*/
$casts = [
'id' => 'int',
];
protected function title() {
return ucwords($this->raw('title'));
}
protected function tags() {
return preg_split('/[,\s]+/', strtolower($this->raw('tags')));
}
}
With this transformer, the raw input data can be converted into the format our application expects.
use App\Transformers\PodcastTransformer;
use Request; // Illuminate\Http\Request instance via Facade
$transformer = new PodcastTransformer(Request::all());
$transformer->tags; //=> [ 'life', 'productivity', 'new-car', 'personal-finance' ]
The interactor described in the next section requires access to specific data and services to perform it's work. It will expect these dependencies to be available within the context it is given.
<?php namespace App\Contexts;
use App\Podcast;
use App\Repositories\PodcastRepository;
use Deefour\Interactor\Context;
class UpdatePodcastContext extends Context {
/**
* The podcast being updated.
*
* @var Podcast
*/
protected $podcast;
public function __construct(Podcast $podcast, PodcastTransformer $transformer) {
$this->podcast = $podcast;
parent::__construct($transformer);
}
}
The abstract Deefour\Interactor\Context
class expects an array of attributes or a transformer to be passed to it's constructor. It provides access to properties and methods on the transformer directly through the context via magic __get()
and __call()
implementations. This means the 'tags'
property could be accessed as shown below.
use App\Podcast;
use App\Contexts\UpdatePodcastContext;
use App\Transformers\PodcastTransformer;
use Request; // Illuminate\Http\Request instance via Facade
$podcast = Podcast::find(34);
$transformer = new PodcastTransformer(Request::all());
$context = new UpdatePodcastContext($podcast, $transformer);
$context->tags; //=> [ 'life', 'productivity', 'new-car', 'personal-finance' ]
The final piece before tying everything together in the controller action is the interactor - the object that actually persists the changes to the podcast.
<?php namespace App\Commands;
use App\Contexts\UpdatePodcastContext;
use App\Repositories\PodcastRepository;
use Deefour\Interactor\Interactor;
use Illuminate\Contracts\Bus\SelfHandling;
class UpdatePodcast extends Interactor implements SelfHandling {
/**
* The podcast repository.
*
* @var Podcasts
*/
protected $podcasts;
public function __construct(PodcastRepository $podcasts, UpdatePodcastContext $context) {
parent::__construct($context);
$this->podcasts = $podcasts;
}
public function handle() {
$c = $this->context();
$podcast = $c->podcast;
$podcast->title = $c->title;
$this->syncTags($c->tags);
$podcast->save();
}
protected function syncTags(array $tags) {
// omitted for brevity.
}
}
Note: I don't suffix the interactor classname with "Interactor" because I typically implement interactors as self-handling commands within Laravel. My
App\Commands
classes extendDeefour\Interactor\Interactor
and retain all the functionality of normal Laravel commands, including the ability to be queued.
The PodcastRepository
is a type-hinted parameter on the interactor instead of the context because it is a dependency of the interactor not directly containing information from a unique request to perform it's unit of work. Dependency resolution via a service container like Laravel's IoC container is supported by the deefour/interactor
package.
Notice, the syncTags()
can safely type-hint the $tags
argument, because we are guaranteeing the tags pulled from the transformer will be an array.
Finally, the PodcastController
is shown below with the relevant bits for this example.
<?php namespace App\Http\Controllers;
use App\Podcast;
use App\Contexts\UpdatePodcastContext;
use App\Repositories\PodcastRepository;
use App\Transformers\PodcastTransformer;
use Deefour\Authorizer\ProvidesAuthorization;
use Deefour\Interactor\DispatchesInteractors;
use Request;
use Response;
class PodcastController extends Controller {
use DispatchesInteractors;
use ProvidesAuthorization;
/**
* Persists changes to an existing podcast.
*
* @param Podcast $podcast
* @param Request $request
* @return Response
*/
public function update(Podcast $podcast, Request $request) {
$this->authorize($podcast);
$context = new UpdatePodcastContext(
$podcast,
new PodcastTransformer($request->all())
);
$this->dispatchInteractor(UpdatePodcast::class, $context);
if ( ! $context->ok()) {
return redirect()->route('podcasts.edit', compact('podcast'))
->withInput()->withErrors()
->with('flash.notice', 'Unexpected Error: ' . $context->status()->error());
}
return redirect()->route('podcasts.edit', compact('podcast'))
->with('flash.success', 'The changes have been saved');
}
/**
* {@inheritdoc}
*/
protected function currentUser() {
return app('user') ?: new User;
}
}
Implementation Notes:
- In a real application, the use of the inclusion of the traits and
currentUser()
method should be pushed up into the parent controller to make it available throughout the application.- Validation is omitted from the example above for brevity.
- The
currentUser()
method is an implementation of an abstract method defined in theProvidesAuthorization
trait, serving as a framework-agnostic way to retrieve the currently-logged-in user.
Failure and redirection handling aside, the update()
action is composed of only 3 lines of code. Actions stay similarly small for much more complex scenarios as well. Separating authorization, validation (again, omitted above), request data formatting, and business logic from the controller itself helps keep code clean, testable, and easier to maintain.