Matthew Hodge
Full Stack Developer

Using PHP Attributes in Laravel

PHP 8.0 introduced attributes as a native way to add structured metadata to classes, methods, properties, and more. Attributes require PHP 8.0 or higher.

Laravel itself already relies heavily on metadata (routes, middleware, validation rules, policies, etc.), so attributes are a natural fit when you want to attach configuration directly to your code instead of scattering it in arrays or config files.

What are PHP attributes?

Attributes are special metadata that you attach using the #[...] syntax. Under the hood they are just classes marked with the built-in #[Attribute] attribute.

use Attribute;

#[Attribute]
class MyAttribute
{
    public function __construct(public string $value)
    {
    }
}

#[MyAttribute('example')]
class SomeService
{
}

At runtime, you can use PHP's reflection API (e.g. ReflectionClass, ReflectionMethod, ReflectionProperty) to read and act on those attributes.

This is the same mechanism described in the official PHP attributes overview, but here we'll focus specifically on how to use it inside a Laravel app.


Declaring a simple attribute

Let's say we want to mark some controller actions as "internal only", meaning they should only be accessible from within our network or by certain roles.

First, we define a custom attribute class in app/Attributes:

// app/Attributes/InternalOnly.php

namespace App\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD)]
class InternalOnly
{
    public function __construct(
        public ?array $allowedIps = null,
    ) {
    }
}
  • Attribute::TARGET_METHOD means this attribute can only be used on methods.
  • The $allowedIps argument lets you configure per-action IP whitelists if needed.

Using attributes on Laravel controllers

Now we can use this attribute on a controller method:

// app/Http/Controllers/ReportController.php

namespace App\Http\Controllers;

use App\Attributes\InternalOnly;
use Illuminate\Http\Request;

class ReportController extends Controller
{
    #[InternalOnly(["10.0.0.1", "10.0.0.2"])]
    public function index(Request $request)
    {
        // Only internal IPs should reach here
        return view('reports.index');
    }
}

This keeps the configuration (InternalOnly, allowed IPs) right next to the method it affects.

Now we need Laravel to respect this attribute, which is where middleware comes in.


Reading attributes in middleware

We'll create a middleware that uses reflection to check for our InternalOnly attribute on the current controller action.

// app/Http/Middleware/CheckInternalOnly.php

namespace App\Http\Middleware;

use App\Attributes\InternalOnly;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

class CheckInternalOnly
{
    public function handle(Request $request, Closure $next)
    {
        $route = $request->route();

        if (! $route) {
            return $next($request);
        }

        $controllerClass = $route->getControllerClass();
        $method          = $route->getActionMethod();

        if (! $controllerClass || ! $method) {
            return $next($request);
        }

        $reflection = new \ReflectionMethod($controllerClass, $method);

        $attributes = $reflection->getAttributes(InternalOnly::class);

        if (empty($attributes)) {
            // No InternalOnly attribute, continue as normal
            return $next($request);
        }

        /** @var InternalOnly $config */
        $config = $attributes[0]->newInstance();

        $clientIp = $request->ip();

        if ($config->allowedIps !== null && ! in_array($clientIp, $config->allowedIps, true)) {
            throw new AccessDeniedHttpException('This endpoint is internal only.');
        }

        return $next($request);
    }
}

Then register the middleware. The location differs between Laravel versions.

Laravel 10 — app/Http/Kernel.php:

protected $routeMiddleware = [
    // ...
    'internal_only' => \App\Http\Middleware\CheckInternalOnly::class,
];

Laravel 11+ — bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'internal_only' => \App\Http\Middleware\CheckInternalOnly::class,
    ]);
})

Finally, apply it to routes that should respect the attribute:

Route::middleware(['internal_only'])
    ->get('/reports', [\App\Http\Controllers\ReportController::class, 'index']);

Now the InternalOnly attribute is actually enforced.

  • If an action has the attribute, the middleware checks the IP.
  • If not, the request continues normally.

Using attributes for console commands and services

Laravel uses Symfony Console under the hood, and modern Symfony allows defining commands using attributes like #[AsCommand]. You can adopt a similar pattern for your own internal tooling.

Example: tagging services with attributes

Let's say you have certain classes that should be auto-registered for a specific process, like scheduled tasks or background workers.

Define a Task attribute:

// app/Attributes/Task.php

namespace App\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
class Task
{
    public function __construct(
        public string $name,
        public ?string $schedule = null,
    ) {
    }
}

Use it on a class:

// app/Tasks/CleanOldReports.php

namespace App\Tasks;

use App\Attributes\Task;

#[Task('clean-old-reports', schedule: '0 3 * * *')]
class CleanOldReports
{
    public function __invoke(): void
    {
        // Clean up old records...
    }
}

In a service provider, you could scan a namespace for classes with the Task attribute and register them dynamically (e.g. into the scheduler or a custom registry).

// app/Providers/TaskServiceProvider.php

namespace App\Providers;

use App\Attributes\Task;
use Illuminate\Support\ServiceProvider;
use ReflectionClass;

class TaskServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $taskClasses = [
            \App\Tasks\CleanOldReports::class,
            // Add more or discover them dynamically
        ];

        foreach ($taskClasses as $taskClass) {
            $reflection = new ReflectionClass($taskClass);

            $attributes = $reflection->getAttributes(Task::class);

            if (empty($attributes)) {
                continue;
            }

            /** @var Task $task */
            $task = $attributes[0]->newInstance();

            // Here you could register the task somewhere, e.g.:
            // app(Schedule::class)->call(new $taskClass())->cron($task->schedule);
            // (The line above is intentionally simplified pseudocode — you'd
            //  need to resolve the Schedule instance and handle any dependencies.)
        }
    }
}

This gives you a declarative way to describe scheduled tasks or background jobs without maintaining big configuration arrays.


When to use attributes in Laravel

Attributes are useful in Laravel when you want to:

  • Attach metadata directly to controllers, models, or services.
  • Build declarative APIs for things like permissions, throttling, logging, or caching.
  • Reduce duplication between configuration arrays and the code they configure.
  • Build your own framework-like features on top of Laravel.

They may not be a good fit when:

  • The configuration is highly dynamic and depends on runtime data (per tenant, per user, etc.).
  • A simple config file or .env variable is clearer.
  • You're adding complexity without a clear benefit.

Final thoughts

PHP attributes give you a powerful, native way to attach metadata directly to your Laravel code. By combining them with Laravel's middleware, service providers, and scheduling, you can build expressive, declarative APIs tailored to your application.

Start small: create a single attribute (like InternalOnly) and wire it into middleware. Once you're comfortable with the pattern, you'll find many opportunities to use attributes to replace scattered configuration arrays and make your Laravel codebase clearer and easier to maintain.


Building AI Agents in Laravel with Prism

AI features have gone from "interesting experiment" to something clients are genuinely asking for. The challenge as a Laravel developer has been that the official SDKs from Anthropic, OpenAI, and others are PHP-first but not Laravel-first — you end up writing a lot of plumbing yourself.

Prism solves this. It's a Laravel-native AI SDK that wraps multiple providers (Anthropic, OpenAI, Gemini, Mistral, and others) behind a consistent, fluent API. Provider swaps are a config change, tool-use and agents work the same way regardless of which model you're using, and it slots into Laravel's service container naturally.

In this post, I'll walk through installing Prism, generating text, working with structured output, and building a practical agent with tools.


Installation

composer require echolabs/prism
php artisan vendor:publish --tag=prism-config

This creates config/prism.php. Add your provider credentials to .env:

ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...

Configure your default provider and model in config/prism.php:

'default_provider' => env('PRISM_PROVIDER', 'anthropic'),
'default_model'    => env('PRISM_MODEL', 'claude-sonnet-4-6'),

Simple Text Generation

The most basic use case — generate text from a prompt:

use EchoLabs\Prism\Facades\Prism;
use EchoLabs\Prism\Enums\Provider;

$response = Prism::text()
    ->using(Provider::Anthropic, 'claude-sonnet-4-6')
    ->withPrompt('Explain what a Laravel service provider does in two sentences.')
    ->generate();

echo $response->text;

Or using your configured defaults:

$response = Prism::text()
    ->withPrompt('What is the difference between a Job and an Event in Laravel?')
    ->generate();

echo $response->text;

System Prompts and Messages

For anything beyond a single prompt, you'll want a system message and a message history. This is how you shape the model's persona or context:

use EchoLabs\Prism\ValueObjects\Messages\UserMessage;
use EchoLabs\Prism\ValueObjects\Messages\AssistantMessage;

$response = Prism::text()
    ->using(Provider::Anthropic, 'claude-sonnet-4-6')
    ->withSystemPrompt('You are a helpful assistant specialising in Laravel and PHP. Keep responses concise and practical.')
    ->withMessages([
        new UserMessage('How do I eager load relationships in Laravel?'),
        new AssistantMessage('Use `with()` on your query: `User::with("posts")->get()`...'),
        new UserMessage('What about nested relationships?'),
    ])
    ->generate();

echo $response->text;

This is the foundation of building a multi-turn chat feature.


Structured Output

Instead of parsing plain text, you can tell the model to return structured data matching a schema. Prism handles the JSON extraction and validation:

use EchoLabs\Prism\Schema\ObjectSchema;
use EchoLabs\Prism\Schema\StringSchema;
use EchoLabs\Prism\Schema\ArraySchema;

$schema = new ObjectSchema(
    name: 'ticket_triage',
    description: 'Triage result for a support ticket',
    properties: [
        new StringSchema('priority', 'Priority level: low, medium, high, or critical'),
        new StringSchema('category', 'Issue category: billing, technical, account, or other'),
        new StringSchema('summary', 'One-sentence summary of the issue'),
        new ArraySchema('tags', 'Relevant tags', new StringSchema('tag', 'A tag')),
    ],
    requiredFields: ['priority', 'category', 'summary']
);

$response = Prism::structured()
    ->using(Provider::Anthropic, 'claude-sonnet-4-6')
    ->withSystemPrompt('You are a support ticket triage assistant.')
    ->withPrompt('Ticket: "I can\'t log in, it says my password is wrong but I just reset it 5 minutes ago."')
    ->withSchema($schema)
    ->generate();

$result = $response->structured;
// [
//     'priority' => 'high',
//     'category' => 'account',
//     'summary'  => 'User cannot log in despite recently resetting their password.',
//     'tags'     => ['authentication', 'password-reset', 'login'],
// ]

You get a typed, validated array back — ready to store in the database or act on directly.


Building an Agent with Tools

This is where Prism gets genuinely interesting. Agents are models that can call tools (PHP functions you define) to take actions or retrieve information, then use the results to continue reasoning.

Defining Tools

use EchoLabs\Prism\Tool;

$getOrderStatus = Tool::as('get_order_status')
    ->for('Retrieve the current status of a customer order by order ID')
    ->withStringParameter('order_id', 'The order ID to look up')
    ->using(function (string $order_id): string {
        $order = Order::find($order_id);

        if (! $order) {
            return "Order {$order_id} not found.";
        }

        return "Order {$order_id}: status={$order->status}, placed={$order->created_at->toDateString()}, total=\${$order->total}";
    });

$getCustomerOrders = Tool::as('get_customer_orders')
    ->for('List recent orders for a customer by their email address')
    ->withStringParameter('email', 'The customer email address')
    ->using(function (string $email): string {
        $customer = Customer::where('email', $email)->first();

        if (! $customer) {
            return "No customer found with email {$email}.";
        }

        $orders = $customer->orders()->latest()->limit(5)->get();

        if ($orders->isEmpty()) {
            return "Customer {$email} has no orders.";
        }

        return $orders->map(fn($o) => "#{$o->id} - {$o->status} - \${$o->total} - {$o->created_at->toDateString()}")->join("\n");
    });

Running the Agent

$response = Prism::text()
    ->using(Provider::Anthropic, 'claude-sonnet-4-6')
    ->withSystemPrompt('You are a customer support agent for an e-commerce store. Use the tools available to look up order and customer information before answering questions.')
    ->withPrompt('Can you check on my recent orders? My email is jane@example.com')
    ->withTools([$getOrderStatus, $getCustomerOrders])
    ->withMaxSteps(5)
    ->generate();

echo $response->text;
// "I found your recent orders, Jane! Here's what I can see:
//  - Order #1042 — completed — $89.99 — March 10
//  - Order #1038 — shipped — $234.00 — February 28
//  ..."

withMaxSteps controls how many tool call rounds the agent can make. The model calls a tool, gets the result, decides whether to call another tool or respond to the user, and keeps going until it has an answer or hits the step limit.


A Practical Example: Article Tag Suggester

Here's a complete, practical example — a class that takes a blog post body and suggests tags using structured output:

// app/AI/ArticleTagger.php

namespace App\AI;

use EchoLabs\Prism\Facades\Prism;
use EchoLabs\Prism\Enums\Provider;
use EchoLabs\Prism\Schema\ObjectSchema;
use EchoLabs\Prism\Schema\StringSchema;
use EchoLabs\Prism\Schema\ArraySchema;

class ArticleTagger
{
    private ObjectSchema $schema;

    public function __construct()
    {
        $this->schema = new ObjectSchema(
            name: 'article_tags',
            description: 'Suggested tags and metadata for a blog article',
            properties: [
                new ArraySchema('tags', 'Suggested tags (3-6)', new StringSchema('tag', 'A tag')),
                new StringSchema('primary_category', 'The primary technical category'),
                new StringSchema('difficulty', 'Difficulty level: beginner, intermediate, or advanced'),
            ],
            requiredFields: ['tags', 'primary_category', 'difficulty']
        );
    }

    public function suggest(string $title, string $body): array
    {
        $response = Prism::structured()
            ->using(Provider::Anthropic, 'claude-haiku-4-5-20251001') // cheaper model for this task
            ->withSystemPrompt('You are a technical blog editor. Analyse the article and return appropriate tags and metadata.')
            ->withPrompt("Title: {$title}\n\n{$body}")
            ->withSchema($this->schema)
            ->generate();

        return $response->structured;
    }
}

Use it in a controller or action:

$tagger  = new ArticleTagger();
$result  = $tagger->suggest($post->title, $post->body);

$post->update([
    'tags'     => $result['tags'],
    'category' => $result['primary_category'],
]);

Token Usage and Cost Tracking

Prism exposes token usage so you can track costs:

$response = Prism::text()
    ->withPrompt('Summarise this article...')
    ->generate();

$usage = $response->usage;

Log::info('AI usage', [
    'input_tokens'  => $usage->promptTokens,
    'output_tokens' => $usage->completionTokens,
]);

Store this in a database table if you want per-user or per-feature cost reporting.


Swapping Providers

One of Prism's best features is that your application code doesn't change when you swap providers. If you want to test OpenAI vs Anthropic, change the provider in the using() call — the rest stays the same:

// Was: Provider::Anthropic, 'claude-sonnet-4-6'
// Now:
->using(Provider::OpenAI, 'gpt-4o')

Or make it a config value and switch via .env:

->using(
    config('prism.default_provider'),
    config('prism.default_model')
)

Final Thoughts

Prism lowers the barrier to adding AI features to a Laravel app significantly. The fluent API is familiar, tools make agent-style features practical without a lot of boilerplate, and switching providers is trivial.

Start with something small — a summariser, a tag suggester, a support ticket classifier — and get comfortable with the request/response cycle. Once you've got that pattern down, building more sophisticated agents with multiple tools is a natural extension.

The Laravel and AI space is moving fast, so keep an eye on the Prism docs for new provider support and features.


Leveraging Laravel Events: Forgotten Cart Example & User Activity Logging

Laravel’s event system is a powerful way to decouple different parts of your application. By using events and listeners, you can trigger side effects (like sending emails or logging activity) without cluttering your core business logic. In this post, we’ll explore how events work in Laravel, and walk through a practical example: handling a "forgotten cart" scenario in an e-commerce app, and logging user activity via an event listener.


Why Use Events?

Events allow you to:

  • Decouple logic: Keep your controllers and services clean by moving side effects elsewhere.
  • Easily extend behavior: Add more listeners without touching the core logic.
  • Promote single responsibility: Each listener does one thing well.

Forgotten Cart Events in Practice

Overview

Imagine you want to remind users who have added items to their cart but haven’t checked out after a certain period. Here’s how you might model this with events in Laravel.

Step 1: Create the Event

php artisan make:event ForgottenCart

This generates an event class in app/Events/ForgottenCart.php:

namespace App\Events;

use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use App\Models\User;

class ForgottenCart
{
    use Dispatchable, SerializesModels;

    public $user;

    public function __construct(User $user)
    {
        $this->user = $user;
    }
}

Step 2: Create a Listener to Send Reminder Email

php artisan make:listener SendForgottenCartReminder --event=ForgottenCart

This will generate a listener in app/Listeners/SendForgottenCartReminder.php:

namespace App\Listeners;

use App\Events\ForgottenCart;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailer;

class SendForgottenCartReminder implements ShouldQueue
{
    public function handle(ForgottenCart $event)
    {
        // Send reminder email logic
        \Mail::to($event->user->email)->send(new \App\Mail\ForgottenCartMail($event->user));
    }
}

Step 3: Create a Listener to Log User Activity

php artisan make:listener LogForgottenCartActivity --event=ForgottenCart

In app/Listeners/LogForgottenCartActivity.php:

namespace App\Listeners;

use App\Events\ForgottenCart;
use Illuminate\Support\Facades\Log;

class LogForgottenCartActivity
{
    public function handle(ForgottenCart $event)
    {
        Log::info('User forgot cart', ['user_id' => $event->user->id]);
    }
}

Event Registration in Laravel

Automatic Discovery of Event Listeners

By default, Laravel will automatically find and register your event listeners by scanning your application's Listeners directory. When Laravel finds any listener class method that begins with handle or __invoke, it will register those methods as event listeners for the event that is type-hinted in the method's signature.

You usually don't have to manually register listeners if you follow Laravel's conventions.

Manually Registering Events

If you do want to manually register your events and listeners, you can do so using the Event facade, typically within the boot method of your application's AppServiceProvider:

use App\Domain\Orders\Events\PodcastProcessed;
use App\Domain\Orders\Listeners\SendPodcastNotification;
use Illuminate\Support\Facades\Event;

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    Event::listen(
        PodcastProcessed::class,
        SendPodcastNotification::class,
    );
}

Listing Registered Event Listeners

You can list all of the listeners registered within your application with the following Artisan command:

php artisan event:list

Dispatching and Scheduling Forgotten Cart Events

Dispatching the Event

Whenever your business logic detects a forgotten cart, dispatch the event, both of these approaches will fire the event and trigger any listeners:

ForgottenCart::dispatch($user);
// OR
event(new ForgottenCart($user));

ForgottenCart::dispatch($user) vs event(new ForgottenCart($user))

Which should you use?

  • ForgottenCart::dispatch($user); is the preferred, modern, and more expressive syntax for custom events that use the Dispatchable trait. It makes your intent clear and is recommended by Laravel.
  • event(new ForgottenCart($user)); is a generic way to fire any event, including framework or third-party events, and is useful if the event does not use the Dispatchable trait.

Best practice: For your own custom events, always use ForgottenCart::dispatch($user); for clarity and maintainability.

When Should a Forgotten Cart Event Fire?

A "forgotten cart" event should typically fire when:

  • The user has added items to their cart but has not checked out.
  • A certain period of inactivity has passed (e.g., 6, 12, or 24 hours since the last cart update).
  • The cart still contains items and the user is identifiable.
  • No purchase has been made for that cart since the last update.

This is often handled by a scheduled task that runs periodically and checks for qualifying carts.

Example: Scheduled Command to Dispatch Forgotten Cart Events

You can use Laravel's scheduler to automate this process. First, create a custom Artisan command:

php artisan make:command DispatchForgottenCarts

In app/Console/Commands/DispatchForgottenCarts.php:

namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Models\User;
use App\Events\ForgottenCart;
use Carbon\Carbon;

class DispatchForgottenCarts extends Command
{
    protected $signature = 'cart:dispatch-forgotten';
    protected $description = 'Dispatch ForgottenCart events for users with inactive carts';

    public function handle()
    {
        $cutoff = Carbon::now()->subHours(12); // e.g., 12 hours of inactivity
        $users = User::whereHas('cart', function ($query) use ($cutoff) {
            $query->where('updated_at', '<', $cutoff)
                  ->whereHas('items');
        })->get();

        foreach ($users as $user) {
            ForgottenCart::dispatch($user);
            $this->info("Dispatched ForgottenCart event for user ID: {$user->id}");
        }
    }
}

Scheduling the Command

For Laravel 10 and below: Schedule the command in app/Console/Kernel.php:

protected function schedule(Schedule $schedule)
{
    $schedule->command('cart:dispatch-forgotten')->hourly();
}

For Laravel 11 and above: Scheduling is now handled in routes/console.php:

// routes/console.php

schedule(function ($schedule) {
    $schedule->command('cart:dispatch-forgotten')->hourly();
});

This setup ensures your app automatically checks for and handles forgotten carts, keeping your event-driven logic clean and automated.


Benefits of This Approach

  • Separation of concerns: Emailing and logging are handled independently.
  • Scalability: Add more listeners (e.g., send SMS, trigger push notification) without modifying your core logic.
  • Testability: Listeners can be tested in isolation.

Conclusion

Laravel’s event system is a great way to keep your codebase clean and modular. By using events for things like forgotten carts and user activity logging, you can build applications that are both maintainable and easy to extend.


There's no one size fits all solution when it comes to structuring your Laravel applications. The right pattern depends on many factors such as team size, project complexity, long-term maintenance, and even your team's familiarity with certain approaches. What works for a solo developer on a small project might not scale for a large team building a business-critical application.

As Laravel developers, most of us start by putting business logic directly in controllers. It's quick, simple, and gets the job done, until your app grows and “fat controllers” start to slow you down. At that point, it's time to reach for better patterns.

In this post, I'll walk through five common approaches to structuring business logic in Laravel: Controllers, Service Classes, Action Classes, Domain-Driven Design (DDD), and the Repository Pattern. I'll share when to use each, their pros and cons, and practical examples.


1. Controllers (CRUD)

The classic approach: keep everything in the controller.

When to Use:

  • Small projects
  • Prototypes
  • Quick-and-dirty features

Pros:

  • Fast to write
  • Easy to follow for beginners

Cons:

  • Controllers get bloated (“fat controllers”)
  • Hard to test
  • Logic is not reusable

Example:

// app/Http/Controllers/PostController.php

public function store(Request $request)
{
    $validated = $request->validate([
        'title' => 'required|string',
        'body' => 'required|string',
    ]);
    $post = Post::create($validated);
    // Maybe some more business logic here...
    return redirect()->route('posts.index');
}

2. Service Classes

As your app grows, business logic in controllers becomes hard to manage. Service classes move this logic into dedicated classes, keeping controllers slim.

When to Use:

  • Medium to large projects
  • When logic is reused in multiple places
  • For improved testability

Pros:

  • Keeps controllers clean
  • Logic is reusable
  • Easier to test

Cons:

  • More files/structure
  • Can become “anemic” (just wrappers) if not careful

Example:

// app/Http/Controllers/PostController.php
public function construct(PostService $service){
    $this->service = $service;
}

public function store(Request $request)
{
    $post = $this->service->create($request->validated());
    return redirect()->route('posts.index');
}

// app/Services/PostService.php
namespace App\Services;

class PostService
{
    public function create(array $data)
    {
        // Business logic for creating a post
        return Post::create($data);
    }
}

3. Action Classes (Action Pattern)

Action classes encapsulate a single, focused task—like “CreatePost” or “SendInvoice.” They're great for isolating business logic, making it reusable and testable.

When to Use:

  • When you want to encapsulate and reuse specific business operations
  • For clear, single-responsibility units of work

Pros:

  • Highly focused and reusable
  • Easy to test
  • Encourages single responsibility

Cons:

  • Can result in many small files
  • May overlap with service classes if not careful

Example:

// app/Http/Controllers/PostController.php
public function store(Request $request, CreatePost $action)
{
    $post = $action->execute($request->validated());
    return redirect()->route('posts.index');
}

// app/Actions/CreatePost.php
namespace App\Actions;

use App\Models\Post;

class CreatePost
{
    public function execute(array $data)
    {
        // Business logic for creating a post
        return Post::create($data);
    }
}

Service Classes vs Action Classes: What's the Difference?

At first glance, Service Classes and Action Classes in Laravel look very similar: both move business logic out of controllers and into dedicated classes, both make your code more testable and reusable, and both help keep your controllers slim.

But there are some key differences in philosophy and usage:

Service Classes

  • Purpose: Group related business logic that spans multiple actions or concerns. For example, a PostService might handle creating, updating, and deleting posts, as well as more complex post-related operations.
  • Structure: Typically contains multiple public methods, each representing a different operation.
  • Example Use: When you have a set of related operations that share dependencies or need to coordinate logic.
class PostService
{
    public function create(array $data) { /* ... */ }
    public function update(Post $post, array $data) { /* ... */ }
    public function delete(Post $post) { /* ... */ }
}

Pros:

  • Centralizes related logic
  • Good for operations that are conceptually grouped
  • Can manage dependencies via constructor injection

Cons:

  • Can become bloated with too much logic
  • Less explicit about single responsibilities

Action Classes

  • Purpose: Encapsulate a single, focused operation or “action” (e.g., CreatePost, SendWelcomeEmail). Each class does one thing and does it well.
  • Structure: Usually one public method (often execute or handle), representing the action.
  • Example Use: When you want to isolate a specific task, especially if it will be reused in multiple places (controllers, jobs, events, etc.).
class CreatePost
{
    public function execute(array $data) { /* ... */ }
}

Pros:

  • Highly focused and easy to test
  • Encourages single responsibility principle
  • Easy to compose and reuse in different parts of your app (controllers, jobs, commands, etc.)

Cons:

  • Can result in many small files
  • If not organized, may lead to scattered logic

When to Use Each?

  • Use a Service Class when you have a set of related operations that share dependencies or need to coordinate logic. For example, a UserService that manages user registration, profile updates, and password resets.
  • Use an Action Class when you want to isolate a single operation, especially if it will be reused in multiple places. For example, a CreateInvoice action that you call from a controller, a scheduled job, or an event listener.

Tip:
You can also combine both! A Service Class might orchestrate several Actions, or Actions might use a Service internally for shared logic.

Practical Example

Suppose your app lets users create posts, and you want to send a notification after a post is created.

With Service Class:

  • PostService@create handles both saving the post and sending the notification.

With Action Class:

  • CreatePost@execute handles saving the post.
  • SendPostNotification@execute handles the notification.
  • The controller (or a service) coordinates the two actions.

4. Domain-Driven Design (DDD)

DDD is an advanced approach that organizes code by domain concepts rather than technical type. It's ideal for large, complex, or long-lived projects.

When to Use:

  • Large, complex, or business-critical projects
  • When working with multiple teams
  • When you need clear domain boundaries

Pros:

  • Highly maintainable and scalable
  • Clear boundaries and business language
  • Aligns code with real-world concepts

Cons:

  • Steep learning curve
  • More boilerplate
  • Overkill for small apps

Example Structure:

app/
  Domain/
    Blog/
      Actions/
      ValueObjects/
      Aggregates/
      Repositories/
    User/
  Application/
  Infrastructure/
  Presentation/

You might have a CreatePost action inside Domain/Blog/Actions, and aggregates or value objects representing your business rules.


5. The Repository Pattern

The Repository pattern provides a layer between your business logic and data access. Instead of interacting directly with Eloquent models, your controller, services or actions talk to repositories, making your code more flexible and testable.

When to Use:

  • When you want to decouple your business logic from Eloquent or your database.
  • In large or complex apps, or when following DDD.

Pros:

  • Makes swapping data sources easier (e.g., database, API, cache).
  • Improves testability (you can mock repositories).
  • Enforces a contract for data access.

Cons:

  • Adds extra boilerplate in simple apps.
  • Can be overkill for small projects.

Example:

// app/Repositories/PostRepository.php
interface PostRepository
{
    public function all();
    public function find($id);
    public function create(array $data);
}

// app/Repositories/EloquentPostRepository.php
class EloquentPostRepository implements PostRepository
{
    public function all() { return Post::all(); }
    public function find($id) { return Post::find($id); }
    public function create(array $data) { return Post::create($data); }
}

You can then inject and use PostRepository in your controller, services or actions instead of Eloquent directly.

Binding the Repository in a Service Provider: To make Laravel resolve PostRepository to your EloquentPostRepository, add the following to your AppServiceProvider or a dedicated service provider:

// app/Providers/AppServiceProvider.php

use App\Repositories\PostRepository;
use App\Repositories\EloquentPostRepository;

public function register()
{
    $this->app->bind(PostRepository::class, EloquentPostRepository::class);
}

Using the repository in your controller for example might look something like this:

// app/Http/Controllers/PostController.php
class PostController extends Controller
{
    protected $postRepository;

    public function __construct(protected PostRepository $postRepository)
    {
        $this->postRepository = $postRepository;
    }

    public function index()
    {
        $posts = $this->postRepository->all();
        return view('posts.index', compact('posts'));
    }
}

Comparison Table

PatternWhen to UseProsCons
ControllersSmall/simple appsQuick, simpleFat controllers
Service ClassMedium/large appsTestable, reusableMore structure
Action ClassReusable tasks, clarityFocused, testable, reusableMany small files
DDDLarge/complex appsScalable, maintainableLearning curve
RepositoryAbstraction, DDD, testingDecouples data access, testableBoilerplate, overkill for small apps

Conclusion

There's no one-size-fits-all answer. Start simple, controllers are fine for small apps. As your app grows, refactor business logic into service classes or action classes. For truly complex domains, consider DDD. The key is to recognize when it's time to evolve your architecture.


Using Scoped Relationships in Laravel Eloquent

When working with Laravel Eloquent, you’ll often need to filter or extend your relationships to suit your app’s needs. Scoped relationships let you add custom constraints to your relationships, making your models more expressive and your queries more reusable.

Let’s walk through some concrete examples to help you understand and use scoped relationships in your own projects!

What is a Scoped Relationship?

A scoped relationship is simply a relationship method on your model that applies extra query constraints. For example, you might want to easily fetch only the “featured” posts for a user, not just all posts.

Example: User and Posts

Let’s say you have a User model and a Post model. A user can have many posts:

// app/Models/User.php

public function posts(): HasMany
{
    return $this->hasMany(Post::class)->latest();
}

Now, let’s say you want a quick way to get just the featured posts for a user. You can add a new relationship method that scopes the query:

public function featuredPosts(): HasMany
{
    return $this->posts()->where('featured', true);
}

Now you can do:

$featuredPosts = $user->featuredPosts;

Creating Models via Scoped Relationships

But what if you want to create a new featured post using this relationship? By default, the featured attribute won’t be set automatically:

$post = $user->featuredPosts()->create(['title' => 'My Post']);
// $post->featured is NOT true

Introducing withAttributes

Laravel has a withAttributes function which lets you specify default attributes for models created via the relationship:

public function featuredPosts(): HasMany
{
    return $this->posts()->withAttributes(['featured' => true]);
}

Now, when you create a post via this relationship:

$post = $user->featuredPosts()->create(['title' => 'Featured Post']);
// $post->featured is TRUE!

Customizing Query vs. Creation

By default, withAttributes also adds a where clause to the query. If you only want the default attribute on creation, not as a query constraint, pass asConditions: false:

public function featuredPosts(): HasMany
{
    return $this->posts()->withAttributes(['featured' => true], asConditions: false);
}

When Should You Use Scoped Relationships?

  • When you want to DRY up your code and avoid repeating common query constraints.
  • When you want to make your model API more expressive (e.g., $user->publishedPosts, $user->archivedPosts).
  • When you want to provide sensible defaults for related model creation.

Real-World Use Case

Suppose you’re building a blog platform. You want to let users quickly fetch or create “draft” or “published” posts:

public function draftPosts(): HasMany
{
    return $this->posts()->withAttributes(['status' => 'draft']);
}

public function publishedPosts(): HasMany
{
    return $this->posts()->withAttributes(['status' => 'published']);
}

Now you can easily fetch or create posts in either state:

$draft = $user->draftPosts()->create(['title' => 'My Draft']);
$published = $user->publishedPosts()->create(['title' => 'My Article']);

Conclusion

Scoped relationships are a powerful way to make your Eloquent models more expressive and your code more maintainable. Try adding them to your own models to simplify your queries and model creation!


Laravel's tap helper is a powerful utility that allows you to work with a value and return it without breaking the chain of operations. Let's explore how it works and how to use it effectively.

The TAP Function

At its core, the tap helper is a simple yet powerful function:

if (! function_exists('tap')) {
    /**
     * Call the given Closure with the given value then return the value.
     *
     * @template TValue
     *
     * @param  TValue  $value
     * @param  (callable(TValue): mixed)|null  $callback
     * @return ($callback is null ? \Illuminate\Support\HigherOrderTapProxy : TValue)
     */
    function tap($value, $callback = null)
    {
        if (is_null($callback)) {
            return new HigherOrderTapProxy($value);
        }

        $callback($value);

        return $value;
    }
}

The function does two key things:

  1. If no callback is provided, it returns a HigherOrderTapProxy instance
  2. If a callback is provided, it executes the callback with the value and returns the original value

Basic Usage

The tap helper allows you to "tap" into a chain of methods to perform operations on an object while still returning the original value.

$user = tap(User::first(), function($user) {
    $user->update(['last_login' => now()]);
});

Why Use TAP?

Without tap, you might write:

$user = User::first();
$user->update(['last_login' => now()]);
return $user;

With tap, it's more concise:

return tap(User::first(), function($user) {
    $user->update(['last_login' => now()]);
});

Real-World Examples

Example 1: File Operations

use Illuminate\Support\Facades\Storage;

$path = tap(Storage::put('path/to/file.txt', 'contents'), function() {
    Cache::forget('file-cache');
    Log::info('File was updated');
});
$post = tap(Post::create([
    'title' => 'My Post',
    'content' => 'Post content'
]), function($post) {
    $post->tags()->attach([1, 2, 3]);
    $post->user->notify(new PostCreated($post));
});

Example 3: Collection Manipulation

$processedUsers = tap(User::all(), function($users) {
    $users->each->notify(new WelcomeNotification);
    Cache::put('users', $users);
});

Using TAP with Arrow Functions

PHP 7.4+ allows for more concise syntax:

$user = tap(User::first(), fn($user) => $user->update(['last_login' => now()]));

The tap() Method on Collections

Laravel collections include a tap method:

collect([1, 2, 3])
    ->tap(function($collection) {
        Log::info('Count: ' . $collection->count());
    })
    ->filter(fn($value) => $value > 1)
    ->tap(function($collection) {
        Log::info('Filtered Count: ' . $collection->count());
    });

// Count: 3  
// Filtered Count: 2  

Testing with TAP

TAP is particularly useful in tests:

public function test_user_creation()
{
    $user = tap(User::factory()->create(), function($user) {
        $this->assertDatabaseHas('users', [
            'id' => $user->id,
            'email' => $user->email
        ]);
    });

    $this->actingAs($user);
}

Common Use Cases

  1. Logging Operations:
$response = tap($service->process(), function($result) {
    Log::info('Process completed', ['result' => $result]);
});
  1. Cache Operations:
$posts = tap(Post::all(), function($posts) {
    Cache::put('all_posts', $posts, now()->addDay());
});
  1. File Handling:
$file = tap(new UploadedFile(), function($file) {
    Storage::disk('public')->put('path/to/file', $file);
    event(new FileUploaded($file));
});

Best Practices

  1. Use tap when you need to perform side effects without breaking the chain
  2. Consider tap for cleaner testing code
  3. Combine with arrow functions for more concise syntax
  4. Use collection's tap method when working with collections

Conclusion

The tap helper is a powerful tool for writing cleaner, more maintainable code in Laravel. It's particularly useful for performing side effects while maintaining a fluent interface in your code.

For more information, check out the Laravel documentation.


I have recently been doing some updates to a website I created and had the need to alter the diffForHumans output that carbon provides to a laravel models created at time. Found this pretty interesting and thought I would share.

The system is a listing portal where you are either wanting to buy something other people have listed or alternatively you can list an item you would like to sell.

Difference for humans
Listing::first()->created_at
// => Illuminate\Support\Carbon @1667281103 {#4822
//      date: 2022-11-01 05:38:23.0 UTC (+00:00),
//    }

Listing::first()->created_at->diffForHumans();
// => "10 minutes ago"

Listing::first()->created_at->diffForHumans([
    'parts' => 1
]);
// => "10 minutes ago"

>>> Listing::first()->created_at->diffForHumans([
    'parts' => 2
]);
// => "10 minutes 36 seconds ago"

Listing::first()->created_at->diffForHumans([
    'parts' => 2,
    'join' => ' and '
]);
// => "10 minutes and 36 seconds ago"

I hope you find the above as useful as I have. To find out more head over to nesbot carbon difference for humans and see how you can use it in your project.