Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Manager Laravel Package

graham-campbell/manager

Laravel Manager is a package by Graham Campbell that provides reusable manager functionality for Laravel applications, helping you build and organize driver-based managers and related components cleanly and consistently.

View on GitHub
Deep Wiki
Context7

Getting Started

Laravel Manager is not an application package — there is nothing to install, publish or boot. It is a library you extend when you need your own "manager + drivers" layer (storage backends, SMS providers, CRM sync targets, payment gateways, per-tenant integrations). Treat it as a skeleton you subclass, not a service you consume.

Install and orient yourself

composer require "graham-campbell/manager:^5.3"

^5.3 is the right pin: v5.3 covers PHP 7.4–8.5 and Laravel 8–13, while the v4.x line stops at Laravel 9. There is no service provider to register and no config file to publish.

Before writing anything, read three files in this order — they are the entire public surface:

  1. src/ManagerInterface.php — the contract you will type-hint against (connection, reconnect, disconnect, getConnectionConfig, getDefaultConnection, setDefaultConnection, extend, getConnections).
  2. src/AbstractManager.php — the base class that implements all of it; you supply exactly two methods.
  3. src/ConnectorInterface.php — a one-method interface (connect(array $config)) you can optionally use to split driver construction out of the manager.

Then skim a real implementation — Laravel-Flysystem is the canonical one and is worth ten minutes.

The minimal viable manager

Your config must follow the package's fixed shape: a default key and a connections map keyed by connection name.

// config/reports.php
return [
    'default' => env('REPORTS_CONNECTION', 'local'),
    'connections' => [
        'local' => ['driver' => 'local', 'path' => storage_path('reports')],
        's3'    => ['driver' => 's3', 'bucket' => env('AWS_BUCKET'), 'region' => env('AWS_DEFAULT_REGION')],
    ],
];
namespace App\Reports;

use GrahamCampbell\Manager\AbstractManager;
use InvalidArgumentException;

final class ReportManager extends AbstractManager
{
    protected function createConnection(array $config): ReportDriver
    {
        return match ($config['driver']) {
            'local' => new LocalReportDriver($config['path']),
            's3'    => new S3ReportDriver($config['bucket'], $config['region']),
            default => throw new InvalidArgumentException("Unsupported driver [{$config['driver']}]."),
        };
    }

    protected function getConfigName(): string
    {
        return 'reports'; // the config file / key name
    }
}

Bind it yourself, because the package will not do it for you:

// AppServiceProvider::register()
$this->app->scoped(ReportManager::class, fn ($app) => new ReportManager($app['config']));

Use scoped() rather than singleton() if you run Octane, Horizon or long-lived queue workers (see Gotchas).

First use case

The reason to reach for this on day one is any capability that fans out to more than one backend and may be switched at runtime. The first thing you should wire up is the trivial happy path:

public function __construct(private ReportManager $reports) {}

public function export(Order $order): void
{
    $this->reports->connection()->store("orders/{$order->id}.csv", $csv); // default connection
    $this->reports->connection('s3')->store(...);                          // explicit connection
    $this->reports->store(...);                                            // __call passthrough to default
}

That is the whole package: config-driven lookup, lazy construction, instance pooling, plus a stable contract you can mock.

Implementation Patterns

Type-hint the interface, inject the concrete class. Bind ManagerInterface to your manager in the container and inject ReportManager where you need the concrete type. Consumers should depend on ManagerInterface so tests can swap a stub without touching the driver code.

Lean on __call for the 80% case, and only there. AbstractManager forwards unknown methods to connection() — the default connection. It makes $this->reports->store(...) read nicely for single-backend apps. The moment you have more than one connection in play, write ->connection($name) explicitly; silent routing to the wrong backend is a real production bug class.

Per-tenant / per-region resolution is the killer pattern. Because connections are resolved by name from config on first use and then pooled, multi-tenancy is just a string:

final class UseTenantStorage
{
    public function handle($request, Closure $next)
    {
        $this->reports->setDefaultConnection($request->user()->tenant->storage_connection);

        return $next($request);
    }
}

setDefaultConnection() affects subsequent connection() calls, not already-pooled instances — so run it before anything resolves the default, or pair it with disconnect().

Split driver construction into connectors once createConnection() grows. A match block with eight arms and new chains is a smell. Move each driver into a class implementing ConnectorInterface:

final class S3Connector implements ConnectorInterface
{
    public function connect(array $config): ReportDriver
    {
        return new S3ReportDriver($config['bucket'], $config['region']);
    }
}

…and make createConnection() a lookup table. The interface's real value is that each connector is independently unit-testable with a plain config array.

Use extend() for three things: fakes in tests, app-local drivers that don't belong in the package, and config-selected plugins registered from a service provider's boot(). The resolver is bound to the manager ($this inside the closure is the manager) and receives the connection config.

$this->reports->extend('s3', fn (array $config) => new S3ReportDriver($this->container->make(S3Client::class), $config['bucket']));

Testing pattern. Don't mock the manager; swap the connection.

$manager = app(ReportManager::class);
$manager->extend('local', fn () => new InMemoryReportDriver());
$driver = $manager->connection('local');
// ... act, then assert against $driver, or inspect $manager->getConnections()

getConnections() returning the pool is also your cheapest debugging tool in a REPL: it tells you exactly which connections were actually built and under what names.

Wrap it in a facade only if the ergonomics justify it. Report::store(...) is convenient but hides which connection is in use. Most teams get more value from injecting the manager and keeping the call sites explicit.

Failure handling belongs at the boundary. For connections backed by sockets or tokens (S3, HTTP APIs, message queues), catch the driver's transient exception, call reconnect($name), and retry once. That is precisely what reconnect() exists for and it saves you writing bespoke invalidation logic.

Gotchas and Tips

Nothing is auto-registered. No service provider, no package discovery, no config publish. If $manager->connection() throws "connection not configured", the first thing to check is that your provider actually bound the manager and that getConfigName() matches the top-level config key exactly (reports, not report or app.reports).

The manager only receives a ConfigRepository. AbstractManager::__construct(ConfigRepository $config) means your drivers cannot resolve anything from the container inside createConnection() unless you pass it in. Add a second constructor argument in your subclass and call parent::__construct($config) first. Forgetting the parent:: call is silent — the manager just has no config and every lookup fails.

Pooled connections + long-lived processes = stale credentials. On Octane, Horizon or a daemonised queue worker, a connection created an hour ago keeps its expired token or dead socket. Bind the manager scoped() so it resets per request/job, or explicitly reconnect() after auth failures. This is the single most common runtime bug with this pattern.

disconnect() only forgets. It removes the instance from the pool; it does not close sockets, flush buffers or release resources. If a driver owns a resource, add your own close() and call it before disconnect().

extend() is a no-op for an already-pooled connection. The pool wins. Call disconnect($name) before re-registering an extension — this bites people in test suites where a manager persists across test methods.

Extension lookup is keyed by connection name and by driver name. If a custom creator isn't firing, verify the key you registered matches either the connection name or the exact driver value in that connection's config, and that the connection hasn't already been resolved.

__call masks signature errors. A typo'd method name on the manager becomes a "method does not exist" error on the connection object, with a stack trace pointing at the manager. When the error looks nonsensical, echo get_class($manager->connection()) and check you're on the connection you think you are.

Config caching freezes everything. php artisan config:cache serialises your connections array; env() calls inside the config file are resolved at cache time. If a per-tenant backend must be dynamic, store the connection name in the database and keep credentials in config, rather than building config at runtime.

v5 is strictly typed. Typed properties and return types landed in 5.0, so your createConnection() and getConfigName() signatures must be compatible with the abstract declarations. Adding a covariant return type is fine; narrowing a parameter type is not. On PHP 7.4 you'll also want to double-check your own drivers for return-type mismatches that match can surface loudly.

Debugging checklist when a connection misbehaves: getConnections() (what got built?), getConnectionConfig($name) (what config did it actually read? — also handy for redacted health-check logging), getDefaultConnection() (is the default what you expect?), then reconnect() (is it just stale?). Those four calls resolve nearly every issue without touching a debugger.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package