DEV Community

Cover image for Laravel Service Classes Explained
Farhan Hasin Chowdhury
Farhan Hasin Chowdhury

Posted on • Edited on

Laravel Service Classes Explained

Benjamin Franklin once said —

A place for everything, everything in its place.

This applies to software development as well. Understanding which portion of the code goes where is the key to a maintainable code base.

Laravel being an elegant web framework, comes with a pretty organized directory structure by default but still I've seen a lot of people suffer.

Don't get me wrong. It's a no brainer that controllers go inside the controllers directory, no confusions whatsoever. The thing people often confuse themselves with is, what to write in a controller and what not to.

Table of Content

Project Codes

You can find an implementation of the service discussed in this article in the following repository:

GitHub logo fhsinchy / laravel-livewire-shopping-cart

A real-time shopping cart powered by Laravel, Livewire and TailwindCSS

Apart from Laravel, the project makes use of Livewire and TailwindCSS.

The Scenario

Take the following piece of code for example:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class CartItemController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        $content = session()->has('cart') ? session()->get('cart') : collect([]);
        $total = $content->reduce(function ($total, $item) {
            return $total += $item->get('price') * $item->get('quantity');
        });

        return view('cart.index', [
            'content' => $content,
            'total' => $total,
        ]);
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request)
    {
        $request->validate([
            'name' => 'required|string',
            'price' => 'required|numeric',
            'quantity' => 'required|integer',
        ]);

        $cartItem = collect([
            'name' => $request->name,
            'price' => floatval($request->price),
            'quantity' => intval($request->quantity),
            'options' => $request->options,
        ]);

        $content = session()->has('cart') ? session()->get('cart') : collect([]);

        $id = request('id');

        if ($content->has($id)) {
            $cartItem->put('quantity', $content->get($id)->get('quantity') + $request->quantity);
        }

        $content->put($id, $cartItem);

        session()->put('content', $content);

        return back()->with('success', 'Item added to cart');
    }

    /**
     * Display the specified resource.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function show($id)
    {
        $content = session()->get('cart');

        if ($content->has($id)) {
            $item = $content->get($id);

            return view('cart', compact('item'));
        }

        return back()->with('fail', 'Item not found');
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, $id)
    {
        $content = session()->get('cart');

        if ($content->has($id)) {
            $cartItem = $content->get($id);

            switch ($request->action) {
                case 'plus':
                    $cartItem->put('quantity', $content->get($id)->get('quantity') + 1);
                    break;
                case 'minus':
                    $updatedQuantity = $content->get($id)->get('quantity') - 1;

                    if ($updatedQuantity < 1) {
                        $updatedQuantity = 1;
                    }

                    $cartItem->put('quantity', $updatedQuantity);
                    break;
            }

            $content->put($id, $cartItem);

            session()->put('cart', $content);

            return back()->with('success', 'Item updated in cart');
        }

        return back()->with('fail', 'Item not found');
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function destroy($id)
    {
        $content = session()->get('cart');

        if ($content->has($id)) {
            session()->put('cart', $content->except($id));

            return back()->with('success', 'Item removed from cart');
        }

        return back()->with('fail', 'Item not found');
    }
}
Enter fullscreen mode Exit fullscreen mode

This is a controller from an imaginary e-commerce project, responsible for managing the shopping cart. Although this is a perfectly valid piece of code, there are some problems.

The controller also knows too much. Look at the index method for example. It doesn't need to know whether the cart content comes from the session or database. Neither does it need to know how to calculate the total price of the cart items.

A controller should be only responsible for transporting requests and responses. The inner details aka the business logic should be handled by other classes in the server.

Understanding Business Logic

As explained in this thread:

Business logic or domain logic is that part of the program which encodes the real-world business rules that determine how data can be created, stored, and changed. It prescribes how business objects interact with one another, and enforces the routes and the methods by which business objects are accessed and updated.

In case of a simple shopping cart system, the business logic behind adding an item to cart can be described as follows:

  1. Take necessary product information (id, name, price, quantity) as input.
  2. Validate the input data.
  3. Form a new cart item.
  4. Check if the item already exists in the cart.
  5. If yes, update it's quantity and if no, add the newly formed item to cart.

Now lets have a look at the store method:

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request)
    {
        // validate the input data.
        $request->validate([
            'name' => 'required|string',
            'price' => 'required|numeric',
            'quantity' => 'required|integer',
        ]);

        // form a new cart item.
        $cartItem = collect([
            'name' => $request->name,
            'price' => floatval($request->price),
            'quantity' => intval($request->quantity),
            'options' => $request->options,
        ]);

        // check if the item already exists in the cart.
        $content = session()->has('cart') ? session()->get('cart') : collect([]);
        $id = request('id');
        if ($content->has($id)) {
            // if yes, update it's quantity
            $cartItem->put('quantity', $content->get($id)->get('quantity') + $request->quantity);
        }

        // if no, add the newly formed item to cart.
        $content->put($id, $cartItem);
        $this->session->put('content', $content);

        return back()->with('success', 'Item added to cart');
    }
Enter fullscreen mode Exit fullscreen mode

As you can see, the business logic translates to code pretty accurately. Now the problem is, controllers are not meant to contain business logic.

Service Classes to the Rescue

According to the very popular alexeymezenin/laravel-best-practices repository:

Business logic should be in service class

The idea of service classes is not something built into the framework or documented in the official docs. As a result, different people refer to them differently. At the end of the day, service classes are plain classes responsible for holding the business logic.

A service class for holding the shopping cart related business logic can be as follows:

<?php

namespace App\Services;

use Illuminate\Support\Collection;
use Illuminate\Session\SessionManager;

class CartService {
    const MINIMUM_QUANTITY = 1;
    const DEFAULT_INSTANCE = 'shopping-cart';

    protected $session;
    protected $instance;

    /**
     * Constructs a new cart object.
     *
     * @param Illuminate\Session\SessionManager $session
     */
    public function __construct(SessionManager $session)
    {
        $this->session = $session;
    }

    /**
     * Adds a new item to the cart.
     *
     * @param string $id
     * @param string $name
     * @param string $price
     * @param string $quantity
     * @param array $options
     * @return void
     */
    public function add($id, $name, $price, $quantity, $options = []): void
    {
        $cartItem = $this->createCartItem($name, $price, $quantity, $options);

        $content = $this->getContent();

        if ($content->has($id)) {
            $cartItem->put('quantity', $content->get($id)->get('quantity') + $quantity);
        }

        $content->put($id, $cartItem);

        $this->session->put(self::DEFAULT_INSTANCE, $content);
    }

    /**
     * Updates the quantity of a cart item.
     *
     * @param string $id
     * @param string $action
     * @return void
     */
    public function update(string $id, string $action): void
    {
        $content = $this->getContent();

        if ($content->has($id)) {
            $cartItem = $content->get($id);

            switch ($action) {
                case 'plus':
                    $cartItem->put('quantity', $content->get($id)->get('quantity') + 1);
                    break;
                case 'minus':
                    $updatedQuantity = $content->get($id)->get('quantity') - 1;

                    if ($updatedQuantity < self::MINIMUM_QUANTITY) {
                        $updatedQuantity = self::MINIMUM_QUANTITY;
                    }

                    $cartItem->put('quantity', $updatedQuantity);
                    break;
            }

            $content->put($id, $cartItem);

            $this->session->put(self::DEFAULT_INSTANCE, $content);
        }
    }

    /**
     * Removes an item from the cart.
     *
     * @param string $id
     * @return void
     */
    public function remove(string $id): void
    {
        $content = $this->getContent();

        if ($content->has($id)) {
            $this->session->put(self::DEFAULT_INSTANCE, $content->except($id));
        }
    }

    /**
     * Clears the cart.
     *
     * @return void
     */
    public function clear(): void
    {
        $this->session->forget(self::DEFAULT_INSTANCE);
    }

    /**
     * Returns the content of the cart.
     *
     * @return Illuminate\Support\Collection
     */
    public function content(): Collection
    {
        return is_null($this->session->get(self::DEFAULT_INSTANCE)) ? collect([]) : $this->session->get(self::DEFAULT_INSTANCE);
    }

    /**
     * Returns total price of the items in the cart.
     *
     * @return string
     */
    public function total(): string
    {
        $content = $this->getContent();

        $total = $content->reduce(function ($total, $item) {
            return $total += $item->get('price') * $item->get('quantity');
        });

        return number_format($total, 2);
    }

    /**
     * Returns the content of the cart.
     *
     * @return Illuminate\Support\Collection
     */
    protected function getContent(): Collection
    {
        return $this->session->has(self::DEFAULT_INSTANCE) ? $this->session->get(self::DEFAULT_INSTANCE) : collect([]);
    }

    /**
     * Creates a new cart item from given inputs.
     *
     * @param string $name
     * @param string $price
     * @param string $quantity
     * @param array $options
     * @return Illuminate\Support\Collection
     */
    protected function createCartItem(string $name, string $price, string $quantity, array $options): Collection
    {
        $price = floatval($price);
        $quantity = intval($quantity);

        if ($quantity < self::MINIMUM_QUANTITY) {
            $quantity = self::MINIMUM_QUANTITY;
        }

        return collect([
            'name' => $name,
            'price' => $price,
            'quantity' => $quantity,
            'options' => $options,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

As I've already said, service classes are not something built into the framework hence there is no artisan make command for creating a service class. You keep the classes anywhere you like. I'm keeping my classes inside App/Services directory.

The CartService.php file contains both public and protected methods. The public methods named add(), remove(), update(), clear() are responsible for adding item to cart, removing item from cart, updating cart item quantity and clearing the cart respectively.

The other public methods are content() and total() responsible for returning the cart content and total price of added items respectively.

Finally the protected methods getContent() and createCartItem() are responsible for returning cart content within the class methods and forming a new cart item from the received parameters.

Now that the service class is ready, you need to use it inside the controller. To utilize the service class inside the CartItemController.php file, the code needs to be updated as follows:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Services\CartService;
use App\Http\Requests\CartItemRequest;

class CartItemController extends Controller
{
    protected $cartService;

    /**
     * Instantiate a new controller instance.
     *
     * @return void
     */
    public function __construct(CartService $cartService)
    {
        $this->cartService = $cartService;
    }

    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        $content = $this->cartService->content();
        $total = $this->cartService->total();

        return view('cart.index', [
            'content' => $content,
            'total' => $total,
        ]);
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \App\Http\Requests\CartItemRequest  $request
     * @return \Illuminate\Http\Response
     */
    public function store(CartItemRequest $request)
    {
        $this->cartService->add($request->id, $request->name, $request->price, $request->quantity, $request->options);

        return back()->with('success', 'Item added to cart');
    }

    /**
     * Display the specified resource.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function show($id)
    {
        $content = $this->cartService->content();

        $item = $content->get($id);

        return view('cart', compact('item'));
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, $id)
    {
        $this->cartService->update($id, $request->id);

        return back()->with('success', 'Item updated in cart');
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function destroy($id)
    {
        $this->cartService->remove($id);

        return back()->with('success', 'Item removed from cart');
    }
}
Enter fullscreen mode Exit fullscreen mode

Thanks to zero configuration resolution, the service container resolves any class that doesn't depend on any interface automatically. Hence simply injecting the CartService in the controller constructor does the trick:

class CartItemController extends Controller
{
    protected $cartService;

    /**
     * Instantiate a new controller instance.
     *
     * @return void
     */
    public function __construct(CartService $cartService)
    {
        $this->cartService = $cartService;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now an instance of the CartService class becomes available within the controller and can be accessed as $this->cartService property. Rest of the controller action have been updated to make use of the service and as you can see, the controller has become much cleaner now.

Action Re-usability

Apart from making the controller cleaner, you also get the benefit of the shopping cart related actions being accessible from anywhere. Consider the following livewire component for example:

<?php

namespace App\Http\Livewire;

use App\Facades\Cart;
use Livewire\Component;
use Illuminate\Contracts\View\View;

class ProductComponent extends Component
{
    public $product;
    public $quantity;

    /**
     * Mounts the component on the template.
     *
     * @return void
     */
    public function mount(): void
    {
        $this->quantity = 1;
    }

    /**
     * Renders the component on the browser.
     *
     * @return \Illuminate\Contracts\View\View
     */
    public function render(): View
    {
        return view('livewire.product');
    }

    /**
     * Adds an item to cart.
     *
     * @return void
     */
    public function addToCart(): void
    {
        Cart::add($this->product->id, $this->product->name, $this->product->unit_price, $this->quantity);
        $this->emit('productAddedToCart');
    }
}
Enter fullscreen mode Exit fullscreen mode

You can add, remove, update or clear the shopping cart from anywhere. Prior to the service class implementation, the only way to manage the cart was through HTTP requests. Now you can even manage the cart through artisan commands.

Closing Thoughts

The concept of service classes discussed in this article is nothing concrete and I'm not claiming it to be a silver bullet. It's something that I've used in the past and have had no problem whatsoever. As long as you're thoughtful about using these classes and not overdoing it, you should be good to go.

Top comments (7)

Collapse
 
delmontee profile image
Delmontee • Edited

I seem to remember early Laravel (or atleast the documentation for it) used to have and refer to service classes often, but it sort of faded out. Service classes are absolutely the right thing to do imho. If you need to share a method with more than one controller (or call it in a cron job), it should be a no-brainer. I wish Artisan had a make:service by default, just to standardise the procedure, the folder structure and to help point out that service classes are fully acceptable.

Collapse
 
mellamoadan profile image
Adan ϟ

This is awesome, thanks for sharing.

Something that I didn't see in the example is that the service shouldn't hold anything related with the request and response. If we want to validate the request input, we can do it in a request class. If there is something wrong inside the service method, we should let the controller know so the right http code can be thrown.

Collapse
 
fhsinchy profile image
Farhan Hasin Chowdhury

That just flew under the radar mate. If you look at the code examples, you'll see that I've used a request classes myself. It's just that I'm so used to using request classes, I completely forgot to mention them in the article. I'll update the article ASAP. 😊

Collapse
 
klethonio profile image
klethonio

Hello, thanks for the content. I'm having trouble with selecting a table in de DB. My project has a global scope for schools and i'm trying to get all teachers from one school right in the service class (in your case CartService), but the global scope does not filters by school, the selection gets all teachers. I was testing a local scope and notice that session was not accessible, using session('school_id'). Can you light my way here?

Collapse
 
mouradeljayi profile image
Mourad EL Jayi

Thank you !
This is a good information.

Collapse
 
fhsinchy profile image
Farhan Hasin Chowdhury

I'm glad that you liked it mate.

Collapse
 
jamols09 profile image
jamols09 • Edited

Just quite confused. I don't see how model is used for doing queries. Is it fine to implement query in service ?