Single Responsibility Principle and Laravel

Reading Time: 9 minutes

Developers across the industry are familiar with the Single Responsibility Principle, the idea that our classes, or our functions should have one and only one job to do. This idea helps us better document, understand, write, and update our own code as well as the code of a team member or open source project. The Single Responsibility Principle (or SRP) is a widely adopted method of development that took me a long time to adopt in my own development practices.

As a mostly self-taught web developer (I took classes in college, but never finished a Comp. Sci degree), my first web applications were sloppy, unorganized, and all of my core code was written in a functions.php file that continued to grow and grow based on the features I added to a given application. Then, I discovered the joys of Frameworks like Laravel for back-end development and WordPress for block and component-based development. This is where I was first introduced to the ideas of compartmentalizing my code into specific classes. This is also where I first learned about Object Oriented Programming (OOP) and started to fully understand its power.

Many of us first starting to learn Laravel often approach solving a problem in basically the same way. Let’s look at a common Hello World type example with Laravel. Please note, some of this code is copied from existing projects, with key details changed, so you might see middleware groupings or route naming conventions that aren’t explicitly detailed. I have tried to make the overall code as intuitive as possible.

In the great tradition of all CRUD application tutorials, let’s say we have a blog and we want to create a post in that blog. As long as we aren’t building a Single Page Application (SPA), our basic blog will generally look like this:

  • URIs to access the pages for the posts and actions
  • blade templates for holding the HTML for final rendering
  • A Controller to handle the HTTP requests.

Of course we will also want to have a database to store the post, but it doesn’t really matter much right now, as we will be focusing on the Controllers for the majority of the article.

The basic controller below has the following methods:

  • index – Displays a list of all posts
  • create – Displays the form to create a post
  • store – Stores a post in the database
  • show – Displays a specific post
  • edit – Displays the form to update a specific post
  • update – Updates a previously stored post in the database
  • publish – sets the deleted_at value to null
  • unpublish – sets the deleted_at value to the current timestamp
  • destroy – permanently deletes a post from the database

Our routes file routes/web.php has the following routes:

Route::group(['middleware' => ['can:create posts']], function () {
    Route::get('/blog/post/create',
        'PostController@create')->name('post.create');
    Route::post('/blog/post/store',
        'PostController@store')->name('post.store');
});
Route::get('/blog/post/',
    'PostController@index')->name('post.index');
Route::get('/blog/post/{post}',
    'PostController@show')->name('post.show');

Route::group(['middleware' => ['can:edit posts']], function () {
    Route::get('/blog/post/{post}/edit',
        'PostController@edit')->name('post.edit');
    Route::post('/blog/post/{post}/update',
        'PostController@update')->name('post.update');
});
Route::group(['middleware' => ['can:unpublish posts']], function () {
    Route::get('/blog/post/{post}/delete',
        'PostController@unpublish')->name('post.delete');
});
Route::group(['middleware' => ['can:publish posts']], function () {
    Route::get('/blog/post/{post}/restore',
        'PostController@publish')->name('post.restore');
});
Route::group(['middleware' => ['can:destroy posts']], function () {
    Route::get('/blog/post/{post}/destroy',
        'PostController@destroy')->name('post.destroy');
});

Where the routes are calling the associated methods on the PostController class located in app\Http\Controllers. Our classic Laravel PostController class looks like this:

<?php

namespace App\Http\Controllers;

//core
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;

//model
use App\Models\Post;

//events
use App\Events\PostCreated;
use App\Events\QueuePostForReview;
use App\Events\UpdatePostSchedule;
use App\Events\PostPublished;
use App\Events\PostUnpublished;
use App\Events\PostDestroyed;

class PostController extends Controller
{
    /**
     * Display all posts
     *
     * @return View
     */
    public function index(): View
    {
        return view('posts.index');
    }

    /**
     * Show the form to create a new post
     *
     * @return View
     */
    public function create(): View
    {
        return view('posts.create');
    }

    /**
     * Store a newly created post in the database
     *
     * @param  Request  $request
     * @return RedirectResponse
     */
    public function store(Request $request): RedirectResponse
    {
        $validatedData = $request->validate([
            'title' => 'required|unique:posts|max:255',
            'body' => 'required',
        ]);
     
        // The blog post is valid...
        $post = Post::create([
            'title'         => $validatedData['title'], //the post title
            'body'          => $validatedData['body'],  //the post body
            'deleted_at'    => Carbon::now()            //sets the post to be 'unpublished' by default
            'author'        => Auth::user()->id,        //sets the post author ID
        ]);

        PostCreated::dispatch($post);
        QueuePostForReview::dispatch($post);
        UpdatePostSchedule::dispatch($post);

        return redirect()
            ->route('posts.index')
            ->with('success', 'The post was created, but not published!');
    }

    /**
     * Display a specific post
     *
     * @param  Post  $post
     * @return View
     */
    public function show(Post $post): View
    {
        return view('posts.show', ['post' => $post]);
    }

    /**
     * Show the form to edit a specific post
     *
     * @param  Post  $post
     * @return View
     */
    public function edit(Post $post): View
    {
        return view('posts.edit', ['post' => $post]);
    }


    /**
     * Update a specific post in the database
     *
     * @param  Request  $request
     * @param  Environment  $environment
     * @return RedirectResponse
     */
    public function update(Request $request, $post): RedirectResponse
    {
        $postInstance = Post::withTrashed()->find($post);

        $validatedData = $request->validate([
            'title' => 'required|unique:posts|max:255',
            'body' => 'required',
        ]);

        // The blog post is valid...
        $postInstance->title      = $validatedData['title']; //the post title
        $postInstance->body       = $validatedData['body'];  //the post body
        $postInstance->deleted_at = Carbon::now();           //sets the post to be 'unpublished' by default
        $postInstance->author     = Auth::user()->id;        //sets the post author ID
        $postInstance->save();

        PostUpdated::dispatch($postInstance);
        QueuePostForReview::dispatch($postInstance);
        UpdatePostSchedule::dispatch($postInstance);

        return redirect()
            ->route('posts.index')
            ->with('success', 'The post was updated, but not published!');
    }

    /**
     * SoftDeletes post from Database
     *
     * @param  Post  $post
     * @return RedirectResponse
     */
    public function delete(Post $post): RedirectResponse
    {
        $environment->delete();
        return redirect()->route('posts.index')->with('success', 'The post was successfully unpublished');
    }

    /**
     * Recovers post from SoftDelete
     *
     * @param $post
     * @return RedirectResponse
     */
    public function restore($post): RedirectResponse
    {
        $postInstance = Post::withTrashed()->find($post);
        $postInstance->restore();

        PostUnpublished::dispatch($postInstance);
        QueuePostForReview::dispatch($postInstance);
        UpdatePostSchedule::dispatch($postInstance);

        PostPublished::dispatch($postInstance);

        return redirect()->route('posts.index')->with('success', 'The post was successfully published');
    }

    /**
     * Permanently deletes post from the database
     *
     * @param $post
     * @return RedirectResponse
     */
    public function destroy($post): RedirectResponse
    {
        $postInstance = Post::withTrashed()->find($post);

        PostDestroyed::dispatch($post);

        $postInstance->forceDelete();
        return redirect()->route('posts.index')->with('success', 'The post was permanantly deleted');
    }
}

I won’t be showing the blade templates for this article, because we are primarily focusing on the backend of the project and refactoring code to follow the Single Responsibility Principle.

The above controller is a pretty standard first-draft Laravel controller. It’s something you might see in a Laracast video or the Laravel documentation, but it can be improved to better follow the Single Responsibility Principle.

Written as-is, the PostController handles not only the incoming HTTP request, but it handles error messages, form validation, creating the new Post instance, database management, and eventing. While this does not follow the Single Responsibility Principle, it’s also not a bad way to write a Laravel controller, especially if the controller is small and not going to be updated often. But for something like a content management system (i.e. a blog), we will want to continually add new features, new code, and updates, meaning that over time, this controller will become bloated and difficult to read and update.

With the Single Responsibility Principle, what then is the single job of a Laravel controller? The job of the controller is to take in an HTTP request and return an HTTP response, whether that response is a view and a 200 status code, or a redirect and a 403 forbidden status code. If we were to refactor the PostController to only handle the HTTP requests and return a response, how should we handle the rest of the code that needs to execute when publishing a post?

Personally, I like to create a new set of classes called Processors with static methods. It is possible to write the classes using constructors and non-static methods, the static methods are just a preference of mine. It helps keep a clear separation of duties between Processors, Models, FormRequests, and Controllers.

app
├── Console
├── Events
├── Listeners
├── Exceptions
├── Http
│   ├── Controllers
│   │   ├── Controller.php
│   │   └── PostController.php
│   ├── Middleware
│   └── Requests
│       └── PostFormRequest.php
├── Models
│   └── Post.php
└── Processors
    └── Post
        ├── PostProcessor.php
        └── PostEventProcessor.php

Now, what might a Processor class look like? Let’s examine what a PostProcessor class could look like below:

<?php

namespace App\Processors\Post;

use App\Models\Post;

class PostProcessor
{
    /**
     * @param  $data
     * @return Post
     */
    public function start($data, $type)
    {
        switch($type) {
            case 'create':
                if(Auth::user()->can(['create post'])) {
                    return self::create($data);
                } else {
                    throw new Exception(__('isAuthorized.false'), 1);
                }
                break;
            case 'update':
                if(Auth::user()->can(['edit post'])) {
                    return self::update($data);
                } else {
                    throw new Exception(__('isAuthorized.false'), 1);
                }
                break;
            case 'publish':
                if(Auth::user()->can(['publish post'])) {
                    return self::publish($data);
                } else {
                    throw new Exception(__('isAuthorized.false'), 1);
                }
                break;
            case 'unpublish':
                if(Auth::user()->can(['unpublish post'])) {
                    return self::unpublish($data);
                } else {
                    throw new Exception(__('isAuthorized.false'), 1);
                }
                break;
            case 'destroy':
                if(Auth::user()->can(['destroy post'])) {
                    return self::destroy($data);
                } else {
                    throw new Exception(__('isAuthorized.false'), 1);
                }
                break;
            default:
                throw new Exception(__('processRequest.error'), 1);
        }
    }

    /**
     * @param  array  $requestData
     * @return Post
     */
    private static function create(Array $requestData): Post
    {
        $post = Post::create($requestData);
        $post->deleted_at = Carbon::now();
        $post->save();
        return $post;
    }

    /**
     * @param  array  $requestData
     * @return Post
     */
    private static function update(Array $requestData): Post
    {
        $post = Post::withTrashed()->find($requestData['id']);
        $post->title = $requestData['title'];
        $post->body = $requestData['body'];
        $post->deleted_at = Carbon::now();
        $post->updated_at = Carbon::now();
        $post->save();
        return $post;
    }

    /**
     * @param  Post  $post
     * @return void
     */
    private static function publish($post)
    {
        $postInstance = Post::withTrashed()->find($post);
        $postInstance->deleted_at = null;
        $postInstance->updated_at = Carbon::now();
        $postInstance->save();
        return $post;
    }

    /**
     * @param  Post  $post
     * @return void
     */
    private static function unpublish(Post $post)
    {
        $post->deleted_at = Carbon::now();
        $post->updated_at = Carbon::now();
        $post->save();
        return $post;
    }

    /**
     * @param  $post
     * @return void
     */
    private static function destroy($post)
    {
        $postInstance = Post::withTrashed()->find($post);
        $postInstance->forceDelete();
    }
}

Here, we see methods that correspond to the standard CRUD methods you would find in a Laravel controller. We have a create, update, and destroy method, with two custom methods for publishing and unpublishing a specific post, and a method for starting the processor. Note that I don’t use a constructor here, and again that is because I prefer to access these Processor methods statically.

With the PostProcessor, if we call the start() method, the PostProcessor will handle which other methods to call, or it will throw an exception because the user is unauthorized to perform this action.

Now what about validation? If we want to extract the form request validation from the controller, Laravel comes built in with a set of Classes that handle Form Request Validation. In fact, the validation performed in the controller above just references a helper function for the FormRequest and Validator classes. We can create our own custom FormRequest classes.

<?php

namespace App\Http\Requests\Post;

use Auth;
use Illuminate\Foundation\Http\FormRequest;

class PostFormRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize(): bool
    {
        return Auth::user()->can(['control posts']);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules(): array
    {
        return [
            'title'         => 'required|unique:posts|max:255',
            'body'          => 'required',
        ];
    }

    public function messages(): array
    {
        return [
            'title.required' => 'A post must have a title',
            'title.unique'   => 'A post with that title already exists',
            'title.max'      => 'The title is too long, max title is 255 characters',
            'body.required'  => 'A post body is required',
        ];
    }
}

Now that we have the Form Request built, we can just typehint it in our Controller, and Laravel does the heavy lifting for us.

Okay, we have extracted the Form Request Validation and the Model Instance handling from the Controller. Now all that is left is to extract the Event handling.

<?php

namespace App\Processors\Post;

//models
use App\Models\Post;

//events
use App\Events\PostCreated;
use App\Events\QueuePostForReview;
use App\Events\UpdatePostSchedule;
use App\Events\PostPublished;
use App\Events\PostUnpublished;
use App\Events\PostDestroyed;

class PostEventProcessor
{
    /**
     * @param  $post
     * @param  $type
     */
    public function start($post, $type)
    {
        switch($type) {
            case 'create':
                PostCreated::dispatch($post);
                QueuePostForReview::dispatch($post);
                UpdatePostSchedule::dispatch($post);
            case 'update':
                PostUpdated::dispatch($post);
                QueuePostForReview::dispatch($post);
                UpdatePostSchedule::dispatch($post);
            case 'publish':
                PostPublished::dispatch($post);
            case 'unpublish':
                PostUnpublished::dispatch($post);
                QueuePostForReview::dispatch($post);
                UpdatePostSchedule::dispatch($post);
            case 'destroy':
                PostDestroyed::dispatch($post);
            default:
                throw new Exception(__('processEventRequest.error'), 1);
        }
    }
}

Finally, let’s see what the controller might look like with the refactored code:

<?php

namespace App\Http\Controllers\Post;

//core
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;

//model
use App\Models\Post;

//requests
use App\Http\Requests\Post\PostFormRequest;

//processor
use App\Processor\Post\PostProcessor;
use App\Processor\Post\PostEventProcessor;


class PostController extends Controller
{
    /**
     * Display all posts
     *
     * @return View
     */
    public function index(): View
    {
        return view('post.index');
    }

    /**
     * Show the form to create a new post
     *
     * @return View
     */
    public function create(): View
    {
        return view('post.create');
    }

    /**
     * Store a newly created post in the database
     *
     * @param  StorePostRequest  $request
     * @return RedirectResponse
     */
    public function store(StorePostRequest $request): RedirectResponse
    {
        try {
            $post = PostProcessor::start($request->validated(), 'create');
            PostEventProcessor::start($post, 'create');
            return redirect()
                ->route('posts.show', ['post' => $post])
                ->with('success', __('post.create.success'));
        } catch (\Exception $e) {
            return redirect()
                ->route('posts.index')
                ->with('error', __('post.create.error'));
        }
    }

    /**
     * Display a specific post
     *
     * @param  Post  $post
     * @return View
     */
    public function show(Post $post): View
    {
        return view('post.show', ['post' => $post]);
    }

    /**
     * Show the form to edit a specific post
     *
     * @param  Post  $post
     * @return View
     */
    public function edit(Post $post): View
    {
        return view('post.update', ['post' => $post]);
    }

    /**
     * Update a specific post in the database
     *
     * @param  UpdatePostRequest  $request
     * @param  Post  $post
     * @return RedirectResponse
     */
    public function update(PostFormRequest $request, Post $post): RedirectResponse
    {
        try{
            $updatedPost = PostProcessor::start($request->validated(), 'update');
            PostEventProcessor::start($updatedPost, 'update');
            return redirect()
                ->route('posts.show', ['post' => $updatedPost->id])
                ->with('success', __('post.update.success'));
        } catch (\Exception $e) {
            return redirect()
                ->route('posts.index')
                ->with('success', __('post.update.error'));
        }
    }

    /**
     * SoftDeletes Post from Database
     *
     * @param  Post  $post
     * @return RedirectResponse
     */
    public function unpublish(Post $post): RedirectResponse
    {
        try{
            PostProcessor::start($post, 'unpublish');
            PostEventProcessor::start($post, 'unpublish');
            return redirect()
                ->route('posts.index')
                ->with('success', __('post.unpublish.success'));
        } catch (\Exception $e) {
            return redirect()
                ->route('posts.index')
                ->with('error', __('post.unpublish.error'));
        }
        
    }

    /**
     * Recovers Post from SoftDelete
     *
     * @param $post
     * @return RedirectResponse
     */
    public function publish($post): RedirectResponse
    {
        $post = Post::withTrashed()->where('id', $post)->first();
        try{
            PostProcessor::start($post, 'publish');
            PostEventProcessor::start($post, 'publish');
            return redirect()
                ->route('posts.index')
                ->with('success', __('post.publish.success'));
        } catch (\Exception $e) {
            return redirect()
                ->route('posts.index')
                ->with('error', __('post.publish.error'));
        }
        
    }

    /**
     * Permanently deletes post from the database
     *
     * @param $post
     * @return RedirectResponse
     */
    public function destroy($post): RedirectResponse
    {
        $post = Post::withTrashed()->where('id', $post)->first();
        try{
            PostEventProcessor::start($post, 'destroy');
            PostProcessor::start($post, 'destroy');
            return redirect()
                ->route('posts.index')
                ->with('success', __('post.destroy.success'));
        } catch (\Exception $e) {
            return redirect()
                ->route('posts.index')
                ->with('error', __('post.destroy.error'));
        }
        
    }
}

And that’s it. We have now extracted all of the non-http related code from the previous controller into their own classes and methods. It’s not perfect, but it has made the PostController code more readable and has shifted the business logic out of the HTTP request logic, separating domains of system logic into their own sections. There’s nothing saying that this is the only way to handle writing Single Responsibility Principle based code, rather this is one example of how I have handled the task in projects in the past.

I hope this article is helpful for you and shows the possibilities of expanding your own Laravel applications beyond the Controller-Based applications that are often seen in beginner Laravel projects. Leave a comment and let me know what you think!