Mastering Laravel Package Development: From Composer Init to Custom Attributes

Overview: The Power of Custom Laravel Packages

Building a

package allows you to encapsulate reusable logic, share it across multiple projects, or contribute to the vibrant open-source ecosystem. This tutorial demonstrates how to build a sophisticated caching package named
method-cache
. Unlike a basic "Hello World," this project implements a #[Cachable] PHP attribute that automatically handles method results through the
Laravel Cache
system. This technique reduces boilerplate code and improves readability by replacing messy Cache::remember blocks with clean, declarative metadata.

Prerequisites

To follow this guide, you should have a firm grasp of

or higher, as we utilize modern features like attributes and constructor property promotion. Familiarity with the
Laravel
service container is essential, as package development relies heavily on binding and resolving dependencies. You should also be comfortable with
Composer
for dependency management and have a local development environment prepared.

Key Libraries & Tools

  • Composer
    : The backbone of PHP dependency management. Every Laravel package is, at its core, a Composer package.
  • Orchestra Testbench
    : An indispensable tool that simulates a full
    Laravel
    environment for testing packages without needing a standalone app.
  • Pest PHP
    : A focus-driven testing framework used here for its expressive syntax.
  • Laravel Pint
    : An opinionated PHP code style fixer that ensures your package follows
    Laravel
    standards.
  • Spatie Package Skeleton
    : A popular boilerplate for jump-starting package development (though we build a minimal version here).

Code Walkthrough: Building the Foundation

1. Initializing the Package

Begin by creating a directory and initializing the composer.json file. This defines your namespace and dependencies.

{
    "name": "yourname/method-cache",
    "autoload": {
        "psr-4": {
            "YourName\\MethodCache\\": "src/"
        }
    },
    "require-dev": {
        "orchestra/testbench": "^9.0",
        "pestphp/pest": "^2.0"
    }
}

2. Creating the Service Provider

The Service Provider is the entry point of your package. It registers bindings in the

container and handles auto-discovery. We use the beforeResolving hook to intercept classes and inject our caching logic.

namespace YourName\MethodCache;

use Illuminate\Support\ServiceProvider;

class MethodCacheServiceProvider extends ServiceProvider
{
    public function boot()
    {
        $this->app->beforeResolving(function ($abstract, $app) {
            if (!class_exists($abstract)) return;
            
            if (in_array(HasCachableMethods::class, class_implements($abstract))) {
                // Logic to override the class with a cached proxy
            }
        });

        if ($this->app->runningInConsole()) {
            $this->commands([MethodCacheCommand::class]);
        }
    }
}

3. Implementing the Cachable Attribute

Attributes provide metadata for our methods. We define a target of Attribute::TARGET_METHOD to ensure it's used correctly.

#[Attribute(Attribute::TARGET_METHOD)]
class Cachable
{
    public function __construct(
        public int $ttl = 3600,
        public ?string $key = null
    ) {}
}

4. Crafting the Facade

A Facade provides a static interface to our underlying service. First, create the service class, then the Facade that references it.

// The Facade
namespace YourName\MethodCache\Facades;

use Illuminate\Support\Facades\Facade;

class MethodCache extends Facade
{
    protected static function getFacadeAccessor()
    {
        return 'method-cache-service';
    }
}

Syntax Notes: Reflection and Proxies

This package uses PHP Reflection to inspect classes at runtime. By looking for the #[Cachable] attribute on methods, the package can dynamically decide which logic to wrap in a cache layer. A key technique used here is Method Overriding, where we generate a temporary class that extends the user's service and wraps the original method calls in Cache::remember(). This avoids the "noise" of manual caching inside the business logic.

Practical Examples

Imagine a PodcastService that performs heavy data analysis. Instead of polluting the method with caching logic, you simply tag it:

class PodcastService implements HasCachableMethods
{
    #[Cachable(ttl: 60, key: 'podcast_stats')]
    public function getStatistics(int $id)
    {
        // Heavy database or API work here
        return Podcast::analyze($id);
    }
}

When this service is resolved from the

container, the package automatically ensures that subsequent calls within 60 seconds return the cached result instantly.

Tips & Gotchas

  • Interface Requirement: To optimize performance, we only inspect classes that implement a specific interface (e.g., HasCachableMethods). This prevents the package from running reflection on every single class resolved by the container, which would cause significant overhead.
  • Auto-Discovery: Don't forget to add the extra section to your package's composer.json. This allows
    Laravel
    to automatically register your service provider and facades upon installation.
  • Local Testing: Use a path repository in your main app's composer.json to symlink your package locally. This allows you to see changes in real-time without pushing to
    GitHub
    or
    Packagist
    .
  • Recursion Warning: Be careful when using beforeResolving. If your logic inside that hook triggers another resolution of the same class, you'll end up in an infinite loop. Always add checks to ensure you're only processing the target classes once.
4 min read