Unleash the Potential of Your Laravel App with Service Classes

Unleash the Potential of Your Laravel App with Service Classes

Β·

10 min read

Introduction

Welcome to the world of Laravel service classes! This blog post will show how these little helpers can make your code more organized, reusable, and plain. Think of service classes like your code's personal assistant. Instead of handling every task yourself, your code can delegate some tedious responsibilities to a service class and focus on the important stuff. And let's be honest, who doesn't love delegating tasks? πŸ€·β€β™‚οΈ

You might think, "But I've never heard of service classes before; what are they?" Don't worry; by the end of this post, you'll be a pro at using them. I'll be giving you examples of how service classes can be used in a real-world Laravel application, and trust me, it's gonna be like a magic trick; you'll be like, "how did he do that? 🀯

So sit back, grab a cup of coffee (or tea, I don't judge), and prepare to unleash the power of service classes in your code. β˜•οΈ

Understanding Laravel Service Classes

As a developer, you're probably familiar with the feeling of staring at a controller that's grown so large and complex that it's hard to make sense of what's going on. This is where service classes come in to save the day.

πŸ¦Έβ€β™‚οΈ In short, a service class in Laravel is a class that's specifically designed to handle a specific task or group of tasks. It separates business logic from the controller and keeps your code organized and maintainable. The best part is we can type-hint the dependency in the controller thanks to Laravel's Automatic injection feature.

Let's take the example of building an e-commerce website, and you're tasked with creating a feature for creating orders. Without service classes, all the logic for calculating the total cost, checking inventory, and sending confirmation emails would be crammed into the controller. But with service classes, you can take all that logic and move it into its own class, specifically designed to handle the task at hand. This way, your controller is responsible for handling the request, and the service class is responsible for handling the business logic. 🀝

But it's not just about the organization; service classes also increase the reusability of code. Let's say your boss comes to you and says, "Hey, we're going to build a new feature that's almost the same as the order creation feature but slightly different. Can you do it?" You can simply reuse the same code with service classes, making small adjustments as needed, saving you time and headaches. 🀯

To sum up, service classes in Laravel are a powerful tool for separating business logic, increasing code reusability, and making your code more organized and maintainable. And let's be real; it's always nice to have a little more sanity when coding. 🧠

Implementing Service Classes

We learned that service classes can greatly improve code organization and reusability in Laravel applications. To demonstrate how this works, we will use our e-commerce website example, where we need to create a new order using the store method in the OrderController

Initially, without service classes, the store method would do the following business logic:

  • Calculate the total cost. πŸ’»

  • Check ordered products inventory. πŸ›οΈ

  • Send the confirmation email. πŸ’Œ

Here's the original implementation of the "store" method in the OrderController without a service class:

class OrderController extends Controller
{
    public function store(StoreOrderRequest $request)
    {
        $validatedProducts = $request->validated('products');
        // get the list of products from db by id
        $products = Product::findMany(array_column($validatedProducts, 'id'));
        // Calculate the total cost
        $totalOrderCost = $products->map(function ($product) use ($validatedProducts) {
            $quantity = collect($validatedProducts)->where('id', $product->id)->first()['quantity'];
            return $product->price * $quantity;
        })->sum();

        // Check the inventory and return an error if any product is out of stock
        $outOfStockProduct = $products->first(function ($product) use ($validatedProducts) {
            $quantity = collect($validatedProducts)->where('id', $product->id)->first()['quantity'];
            return $product->inventory < $quantity;
        });
        if ($outOfStockProduct) {
            return response()->json([
                'error' => 'Product ' . $outOfStockProduct->name . ' is out of stock'
            ], 400);
        }

        // Deduct from the inventory
        $products->each(function ($product) use ($validatedProducts) {
            $quantity = collect($validatedProducts)->where('id', $product->id)->first()['quantity'];
            $product->inventory = $product->inventory - $quantity;
            $product->save();
        });

        // Save the order
        $order = new Order;
        $order->user_id = $request->user()->id;
        $order->total_cost = $totalOrderCost;
        $order->save();

        // Send the confirmation email
        SendOrderConfirmationEmail::dispatch($request->user(), $totalOrderCost);

        return response()->json([
            'message' => 'Order created successfully'
        ], 201);
    }
}

As you can see, this code performs three tasks. These tasks are tightly coupled with the controller, making the code harder to test, maintain, and reuse.

Let's see how we can use a service class to make the code more organized πŸ—‚οΈ, flexible πŸ§˜β€β™‚οΈ, and testable πŸ”.

  1. First of all, we have to create a new service class.

NOTE: Laravel doesn't have "php artisan make:service" command, so we need to create that class manually in the following path: "App\Services" as a regular PHP class. In our example, we'll call it OrderService.

// App/Services/OrderService.php
class OrderService
{
    public function createOrder(array $orderedProducts, User $user)
    {
        // get the list of products from db by id
        $originalProducts = Product::findMany(array_column($orderedProducts, 'id'));

        // Calculate the total cost
        $totalOrderCost = $originalProducts->map(function ($product) use ($orderedProducts) {
            $quantity = collect($orderedProducts)->where('id', $product->id)->first()['quantity'];
            return $product->price * $quantity;
        })->sum();

        // Check the inventory and return an error if any product is out of stock
        $outOfStockProduct = $originalProducts->first(function ($product) use ($orderedProducts) {
            $quantity = collect($orderedProducts)->where('id', $product->id)->first()['quantity'];
            return $product->inventory < $quantity;
        });

        if ($outOfStockProduct) {
            throw new OutOfStockException($outOfStockProduct);
        }

        // Deduct from the inventory
        $originalProducts->each(function ($product) use ($orderedProducts) {
            $quantity = collect($orderedProducts)->where('id', $product->id)->first()['quantity'];
            $product->inventory = $product->inventory - $quantity;
            $product->save();
        });

        // Save the order
        $order = new Order;
        $order->user_id = $user->id;
        $order->total_cost = $totalOrderCost;
        $order->save();

        // Send the confirmation email
        SendOrderConfirmationEmail::dispatch($user, $totalOrderCost);

        return $order;
    }
}
  1. Refactor the OrderController

Here's what the OrderController would look like after we create a new service class:

NOTE: the OrderService class is ready to be used in our controller. By simply type-hinting the OrderService class in our controller methods, Laravel will automatically initialize it for us.

class OrderController extends Controller
{
    public function store(StoreOrderRequest $request, OrderService $orderService)
    {
        try {
            $validatedProducts = $request->validated('products');
            // Create the order using the OrderService
            $order = $orderService->createOrder($validatedProducts, $request->user());
            // Return a success response with the created order
            return response()->json([
                'message' => 'Order created successfully',
                'order' => $order
            ], 201);
        } catch (OutOfStockException $e) {
            return response()->json([
                'error' => $e->getMessage()
            ], 400);
        } catch (Exception $e) {
            return response()->json([
                'error' => "The order couldn't be created"
            ], 500);
        }
    }
}

We've made great progress πŸŽ‰ in making our OrderController code clean and simple by relying on the OrderService class to do the heavy lifting πŸ’ͺ. But let's not stop there, my friend! The OrderService class may still look a bit daunting, and testing it could be challenging. πŸ€” How about we take it to the next level and break it into smaller, more manageable pieces? πŸ’Ž I'm confident that we can make it shine like a diamond in the rough with a little effort! πŸ’ͺ

So, here's our createOrder after breaking it into smaller functions

// App/Services/OrderService.php
class OrderService
{
    public function createOrder(array $orderedProducts, User $user)
    {
        // get the list of products from db by id
        $originalProducts = $this->getOriginalProducts($orderedProducts);

        // Calculate the total cost
        $totalOrderCost = $this->calculateTotalOrderCost($originalProducts,  $orderedProducts);

        // Check the inventory and return an error if any product is out of stock
        $this->checkInventoryAndThrowExceptionIfOutOfStock($originalProducts,  $orderedProducts);

        // Deduct from the inventory
        $this->deductInventory($originalProducts,  $orderedProducts);

        // Save the order
        $order = $this->saveOrder($user, $totalOrderCost);

        // Send the confirmation email
        SendOrderConfirmationEmail::dispatch($user, $totalOrderCost);

        return $order;
    }

    private function getOriginalProducts(array $orderedProducts)
    {
        return Product::findMany(array_column($orderedProducts, 'id'));
    }

    private function calculateTotalOrderCost(Collection $originalProducts, array $orderedProducts)
    {
        return
            $originalProducts->map(function ($product) use ($orderedProducts) {
                $quantity = collect($orderedProducts)->where('id', $product->id)->first()['quantity'];
                return $product->price * $quantity;
            })->sum();
    }

    private function checkInventoryAndThrowExceptionIfOutOfStock(Collection $originalProducts, array $orderedProducts)
    {
        $outOfStockProduct =
            $originalProducts->first(function ($product) use ($orderedProducts) {
                $quantity = collect($orderedProducts)->where('id', $product->id)->first()['quantity'];
                return $product->inventory < $quantity;
            });

        if ($outOfStockProduct) {
            throw new OutOfStockException($outOfStockProduct);
        }
    }


    private function deductInventory(Collection $originalProducts, array $orderedProducts)
    {
        $originalProducts->each(function ($product) use ($orderedProducts) {
            $quantity = collect($orderedProducts)->where('id', $product->id)->first()['quantity'];
            $product->inventory = $product->inventory - $quantity;
            $product->save();
        });
    }

    private function saveOrder(User $user, float $totalOrderCost): Order
    {
        $order = new Order;
        $order->user_id = $user->id;
        $order->total_cost = $totalOrderCost;
        $order->save();

        return $order;
    }
}

Alright folks, we've been talking about the testability of our business logic all this time, and now it's finally time to put our money where our mouth is and show you just how testable it is by writing some tests! 🀞

I know, I know, testing can seem like a chore, but trust me, having a solid set of tests will save you so much time and headache in the long run.

Plus, you'll have the added bonus of confidently making changes to your code, knowing that your tests will catch any regressions. πŸ’ͺ

So, let's get down to business and see how easy it is to write tests for our OrderService. πŸ”

class OrderServiceTest extends TestCase
{
    use RefreshDatabase;
    use WithFaker;

    /** @var OrderService */
    private $service;

    /** @var User */
    private $user;

    /** @var Collection */
    private $originalProducts;

    /** @var array */
    private $orderedProducts;

    protected function setUp(): void
    {
        parent::setUp();
        $this->user = User::factory()->create();
        $this->service = new OrderService;

        $this->originalProducts = Product::factory(3)->create([
            'inventory' => 10
        ]);

        $this->orderedProducts = [
            [
                'id' => $this->originalProducts[0]->id,
                'quantity' => 2
            ],
            [
                'id' => $this->originalProducts[1]->id,
                'quantity' => 5
            ],
            [
                'id' => $this->originalProducts[2]->id,
                'quantity' => 3
            ],
        ];
    }

    /** @test */
    public function createOrderShouldReturnsOrder()
    {
        $order = $this->service->createOrder($this->orderedProducts, $this->user);

        $this->assertInstanceOf(Order::class, $order);
        $this->assertDatabaseHas('orders', [
            'user_id' => $this->user->id,
            'id' => $order->id
        ]);
    }

    /** @test */
    public function createOrderShouldDeductsInventory()
    {
        $this->service->createOrder($this->orderedProducts, $this->user);

        $this->originalProducts->each(function ($product) {
            $this->assertDatabaseHas('products', [
                'id' => $product->id,
                'inventory' => $product->inventory - collect($this->orderedProducts)->where('id', $product->id)->first()['quantity']
            ]);
        });
    }

    /** @test */
    public function createOrderShouldThrowsExceptionIfProductIsOutOfStock()
    {
        $this->expectException(OutOfStockException::class);

        $this->originalProducts->first()->update([
            'inventory' => 1
        ]);

        $this->service->createOrder($this->orderedProducts, $this->user);
    }
}

It's time to put our controller to the test and show just how solid our adjustments have made it! πŸ’ͺ Don't worry; testing the controller is a breeze compared to testing the OrderService. We'll just create an instance of the controller and run a few tests on the store method. Let's do this!

class OrderControllerTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();
        \App\Models\User::factory()->create();
    }

    /**
     * @test
     */
    public function shouldCreateANewOrder()
    {
        $product = \App\Models\Product::factory()->create([
            'inventory' => 5
        ]);

        $data = [
            'products' => [
                [
                    'id' => $product->id,
                    'quantity' => 3
                ]
            ]
        ];

        $response = $this->postJson('/api/orders', $data);

        $response->assertStatus(201);
        $this->assertCount(1, Order::all());
        $this->assertEquals(2, Product::first()->inventory);
    }

    /**
     * @test
     */
    public function shouldReturnAnErrorWhenProductIsOutOfStock()
    {
        $product = \App\Models\Product::factory(Product::class)->create([
            'inventory' => 3
        ]);

        $data = [
            'products' => [
                [
                    'id' => $product->id,
                    'quantity' => 4
                ]
            ]
        ];

        $response = $this->postJson('/api/orders', $data);

        $response->assertStatus(400);
        $this->assertCount(0, Order::all());
        $this->assertEquals(3, Product::first()->inventory);
    }
    /**
     * @test
     */
    public function shouldValidatesInputData()
    {
        $product = \App\Models\Product::factory(Product::class)->create([
            'inventory' => 5
        ]);

        $data = [
            'products' => [
                [
                    'id' => $product->id,
                    'quantity' => -1
                ]
            ]
        ];

        $response = $this->postJson('/api/orders', $data);

        $response->assertStatus(422);
        $response->assertJsonValidationErrors(['products.0.quantity']);
        $this->assertCount(0, Order::all());
        $this->assertEquals(5, Product::first()->inventory);
    }
}

Recommendations & Best Practices

Now that we have a better understanding of service classes and how they can help us organize and maintain our code, let me give you some personal tips and guidelines to help you make the most of service classes in your Laravel projects.

  1. Naming Conventions: Give your service classes descriptive and meaningful names. No one wants to waste time figuring out what "MysteryClass" does!

  2. Keep Them Small and Focused: Service classes should be lean, mean, and focused on one specific task. Don't try to cram everything into one class – that's what controllers are for.

  3. Avoid Tight Coupling: Service classes should be loosely coupled and easily reusable. This means they should not depend on specific implementations of other classes or services.

  4. Test Them Thoroughly: Make sure your service classes are thoroughly tested and bug-free. You don't want to find out the hard way that a service class is causing problems in your app.

Conclusion

In conclusion, incorporating service classes into your Laravel projects can take your code organization and maintainability to the next level πŸš€ By encapsulating specific tasks, you can increase your code's reusability, flexibility, and testability. Plus, who doesn't love a cleaner, more organized codebase? πŸ€“

You can check out the source code on GitHub: laravel-service-classes

If you have any questions, feel free to contact me directly on my LinkedIn: https://www.linkedin.com/in/izemomar/

Β