1

I have PostController, here is its method store().

public function store(Request $request)
{
    $this->handleUploadedImage(
        $request->file('upload'),
        $request->input('CKEditorFuncNum')
    );

    $post = Post::create([
        'content'      => request('content'),
        'is_published' => request('is_published'),
        'slug'         => Carbon::now()->format('Y-m-d-His'),
        'title'        => $this->firstSentence(request('content')),
        'template'     => $this->randomTemplate(request('template')),
    ]);
    $post->tag(explode(',', $request->tags));

    return redirect()->route('posts');
}

Method handleUploadedImage() now stored in PostController itself. But I'm going to use it in other controllers. Where should I move it? Not in Request class, because it's not about validation. Not in Models/Post, because it's not only for Post model. And it's not so global function for Service Provider class.

Methods firstSentence() and randomTemplate() are stored in that controller too. They will only be used in it. Maybe I should move them in Models/Post? In that way, how exactly call them in method store() (more specifically, in method create())?

I read the theory, and I understand (hopefully) the Concept of Thin Controllers and Fat Models, but I need some practical concrete advice with this example. Could you please suggest, where to move and how to call these methods?

Famaxis
  • 73
  • 1
  • 1
  • 8
  • This is a matter of opinion, and I voted to close as such. Put it wherever you like. If it's used in multiple controllers it would make sense to me to create your own controller class with the method and inherit from that. – miken32 Jan 22 '21 at 17:17
  • I'm interested in right way, not just putting 'wherever I like'. – Famaxis Jan 22 '21 at 17:21
  • There is no "right way" is my point. – miken32 Jan 22 '21 at 17:41

2 Answers2

5

First, a note: I don't work with Laravel, so I'll show you a general solution, pertinent to all frameworks.

Indeed, the controllers should always be kept thin. But this should be appliable to the model layer as well. Both goals are achievable by moving application-specific logic into application services (so, not into the model layer, making the models fat!). They are the components of the so-called service layer. Read this as well.

In your case, it seems that you can elegantly push the handling logic for uploaded images into a service, like App\Service\Http\Upload\ImageHandler, for example, containing a handle method. The names of the class and method could be chosen better, though, dependent on the exact class responsibility.

The logic for creating and storing a post would go into another application service: App\Service\Post, for example. In principle, this service would perform the following tasks:

  1. Create an entity - Domain\Model\Post\Post, for example - and set its properties (title, content, is_published, template, etc) based on the user input. This could be done in a method App\Service\Post::createPost, for example.
  2. Store the entity in the database, as a database record. This could be done in a method App\Service\Post::storePost, for example.
  3. Other tasks...

In regard of the first task, two methods of the App\Service\Post service could be useful:

  • generatePostTitle, encapsulating the logic of extracting the first sentence from the user-provided "content", in order to set the title of the post entity from it;
  • generatePostTemplate, containing the logic described by you in the comment in regard of randomTemplate().

In regard of the second task, personally, I would store the entity in the database by using a specific data mapper - to directly communicate with the database API - and a specific repository on top of it - as abstraction of a collection of post objects.

Service:

<?php

namespace App\Service;

use Carbon;
use Domain\Model\Post\Post;
use Domain\Model\Post\PostCollection;

/**
 * Post service.
 */
class Post {

    /**
     * Post collection.
     * 
     * @var PostCollection
     */
    private $postCollection;

    /**
     * @param PostCollection $postCollection Post collection.
     */
    public function __construct(PostCollection $postCollection) {
        $this->postCollection = $postCollection;
    }

    /**
     * Create a post.
     * 
     * @param string $content Post content.
     * @param string $template The template used for the post.
     * @param bool $isPublished (optional) Indicate if the post is published.
     * @return Post Post.
     */
    public function createPost(
        string $content,
        string $template,
        bool $isPublished
        /* , ... */
    ) {
        $title = $this->generatePostTitle($content);
        $slug = $this->generatePostSlug();
        $template = $this->generatePostTemplate($template);

        $post = new Post();

        $post
            ->setTitle($title)
            ->setContent($content)
            ->setIsPublished($isPublished ? 1 : 0)
            ->setSlug($slug)
            ->setTemplate($template)
        ;

        return $post;
    }

    /**
     * Store a post.
     * 
     * @param Post $post Post.
     * @return Post Post.
     */
    public function storePost(Post $post) {
        return $this->postCollection->storePost($post);
    }

    /**
     * Generate the title of a post by extracting 
     * a certain part from the given post content.
     * 
     * @return string Generated post title.
     */
    private function generatePostTitle(string $content) {
        return substr($content, 0, 300) . '...';
    }

    /**
     * Generate the slug of a post.
     * 
     * @return string Generated slug.
     */
    private function generatePostSlug() {
        return Carbon::now()->format('Y-m-d-His');
    }

    /**
     * Generate the template assignable to 
     * a post based on the given template.
     * 
     * @return string Generated post template.
     */
    private function generatePostTemplate(string $template) {
        return 'the-generated-template';
    }

}

Repository interface:

<?php

namespace Domain\Model\Post;

use Domain\Model\Post\Post;

/**
 * Post collection interface.
 */
interface PostCollection {

    /**
     * Store a post.
     * 
     * @param Post $post Post.
     * @return Post Post.
     */
    public function storePost(Post $post);

    /**
     * Find a post by id.
     * 
     * @param int $id Post id.
     * @return Post|null Post.
     */
    public function findPostById(int $id);

    /**
     * Find all posts.
     * 
     * @return Post[] Post list.
     */
    public function findAllPosts();

    /**
     * Check if the given post exists.
     * 
     * @param Post $post Post.
     * @return bool True if post exists, false otherwise.
     */
    public function postExists(Post $post);
}

Repository implementation:

<?php

namespace Domain\Infrastructure\Repository\Post;

use Domain\Model\Post\Post;
use Domain\Infrastructure\Mapper\Post\PostMapper;
use Domain\Model\Post\PostCollection as PostCollectionInterface;

/**
 * Post collection.
 */
class PostCollection implements PostCollectionInterface {

    /**
     * Posts list.
     * 
     * @var Post[]
     */
    private $posts;

    /**
     * Post mapper.
     * 
     * @var PostMapper
     */
    private $postMapper;

    /**
     * @param PostMapper $postMapper Post mapper.
     */
    public function __construct(PostMapper $postMapper) {
        $this->postMapper = $postMapper;
    }

    /**
     * Store a post.
     * 
     * @param Post $post Post.
     * @return Post Post.
     */
    public function storePost(Post $post) {
        $savedPost = $this->postMapper->savePost($post);

        $this->posts[$savedPost->getId()] = $savedPost;

        return $savedPost;
    }

    /**
     * Find a post by id.
     * 
     * @param int $id Post id.
     * @return Post|null Post.
     */
    public function findPostById(int $id) {
        //... 
    }

    /**
     * Find all posts.
     * 
     * @return Post[] Post list.
     */
    public function findAllPosts() {
        //...
    }

    /**
     * Check if the given post exists.
     * 
     * @param Post $post Post.
     * @return bool True if post exists, false otherwise.
     */
    public function postExists(Post $post) {
        //...
    }

}

Data mapper interface:

<?php

namespace Domain\Infrastructure\Mapper\Post;

use Domain\Model\Post\Post;

/**
 * Post mapper.
 */
interface PostMapper {

    /**
     * Save a post.
     * 
     * @param Post $post Post.
     * @return Post Post entity with id automatically assigned upon persisting.
     */
    public function savePost(Post $post);

    /**
     * Fetch a post by id.
     * 
     * @param int $id Post id.
     * @return Post|null Post.
     */
    public function fetchPostById(int $id);

    /**
     * Fetch all posts.
     * 
     * @return Post[] Post list.
     */
    public function fetchAllPosts();

    /**
     * Check if a post exists.
     * 
     * @param Post $post Post.
     * @return bool True if the post exists, false otherwise.
     */
    public function postExists(Post $post);
}

Data mapper PDO implementation:

<?php

namespace Domain\Infrastructure\Mapper\Post;

use PDO;
use Domain\Model\Post\Post;
use Domain\Infrastructure\Mapper\Post\PostMapper;

/**
 * PDO post mapper.
 */
class PdoPostMapper implements PostMapper {

    /**
     * Database connection.
     * 
     * @var PDO
     */
    private $connection;

    /**
     * @param PDO $connection Database connection.
     */
    public function __construct(PDO $connection) {
        $this->connection = $connection;
    }

    /**
     * Save a post.
     * 
     * @param Post $post Post.
     * @return Post Post entity with id automatically assigned upon persisting.
     */
    public function savePost(Post $post) {
        /*
         * If $post->getId() is set, then call $this->updatePost() 
         * to update the existing post record in the database.
         * Otherwise call $this->insertPost() to insert a new 
         * post record in the database.
         */
        // ...
    }

    /**
     * Fetch a post by id.
     * 
     * @param int $id Post id.
     * @return Post|null Post.
     */
    public function fetchPostById(int $id) {
        //...    
    }

    /**
     * Fetch all posts.
     * 
     * @return Post[] Post list.
     */
    public function fetchAllPosts() {
        //...
    }

    /**
     * Check if a post exists.
     * 
     * @param Post $post Post.
     * @return bool True if the post exists, false otherwise.
     */
    public function postExists(Post $post) {
        //...
    }

    /**
     * Update an existing post.
     * 
     * @param Post $post Post.
     * @return Post Post entity with the same id upon updating.
     */
    private function updatePost(Post $post) {
        // Persist using SQL and PDO statements...
    }

    /**
     * Insert a post.
     * 
     * @param Post $post Post.
     * @return Post Post entity with id automatically assigned upon persisting.
     */
    private function insertPost(Post $post) {
        // Persist using SQL and PDO statements...
    }

}

In the end, your controller would look something like bellow. By reading its code, its role becomes obvious: just to push the user input to the service layer. The use of a service layer provides the big advantage of reusability.

<?php

namespace App\Controller;

use App\Service\Post;
use App\Service\Http\Upload\ImageHandler;

class PostController {

    private $imageHandler;
    private $postService;

    public function __construct(ImageHandler $imageHandler, Post $postService) {
        $this->imageHandler = $imageHandler;
        $this->postService = $postService;
    }

    public function storePost(Request $request) {
        $this->imageHandler->handle(
            $request->file('upload'),
            $request->input('CKEditorFuncNum')
        );

        $post = $this->postService->createPost(
            request('content'),
            request('template'),
            request('is_published')
            /* , ... */
        );

        return redirect()->route('posts');
    }

}

PS: Keep in mind, that the model layer MUST NOT know anything about where its data is coming from, nor about how the data passed to it was created. So, the model layer must not know anything about a browser, or a request, or a controller, or a view, or a response, or etc. It just receives primitive values, objects, or DTOs ("data transfer objects") as arguments - see repository and data mapper above, for example.

PS 2: Note that a lot of frameworks are talking about repositories, but, in fact, they are talking about data mappers. My suggestion is to follow Fowler's conventions in your mind and your code. So, create data mappers in order to directly access a persistence space (database, filesystem, etc). If your project becomes more complex, or if you just want to have collection-like abstractions, then you can add a new layer of abstraction on top of the mappers: the repositories.

Resources

And a good 4-parts series of Gervasio on sitepoint.com:

PajuranCodes
  • 303
  • 3
  • 12
  • 43
  • Exellent explanation, thank you! I agree with the methods renaming too, now it sounds more correct. I had never thought about repository before, now I will looking for examples specific to Laravel, maybe there are built-in ways. And I'll try to use services as recommended. – Famaxis Jan 23 '21 at 11:55
  • @Famaxis You are welcome. I updated my answer with details about *repositories* and *data mappers* and I also added two very good resources. Have fun and good luck. – PajuranCodes Jan 23 '21 at 14:14
  • Thanks. First link in resources, about architecture, seems broken: "Application error". – Famaxis Jan 23 '21 at 14:31
  • @Famaxis This shouldn't be a problem. I updated the link. If it's not working, just search for _"architecture the lost years"_ on the internet. Also the term "clean architecture" should provide great learning sources, in order to build good structured MVC-based apps. Thank you for accepting my answer. – PajuranCodes Jan 23 '21 at 14:53
  • @Famaxis Hi. I updated the answer with some code and text changes. – PajuranCodes Jan 27 '21 at 18:08
  • Wow, thank you again, there is so much for me to learn about. I will recurrently look at this example during my work. – Famaxis Jan 27 '21 at 21:03
  • @Famaxis With pleasure. – PajuranCodes Jan 28 '21 at 05:20
0

What i usually do

public function store(ValidationRequest $request)
{
    $result = $this->dispatchNow($request->validated())
    return redirect()->route('posts');
}

So, i create a job for handling those registration steps, and i can reuse that in another parts of my system.

Your firstSentence i would move to an helper called strings app\helpers\strings (and don't forget to update that in composer.json and you could use just firstSentence($var) in any part of your system

The randomTemplate would fit nicelly in a trait, but i don't know what this methods does.

  • Thanks. The `randomTemplate()` generates random number and selects, which design template will be applied to the post. – Famaxis Jan 22 '21 at 17:44