vimex/storage-utils
vimex/storage-utils is a lightweight Laravel/PHP utility package for common storage tasks, offering reusable helpers to simplify file and path operations and streamline everyday storage workflows in your applications.
vimex/storage-utils is a 2018-era republication of Akeneo's StorageUtils component. Despite the name, it has nothing to do with filesystems, S3, or Laravel's Storage facade. It is a tiny set of interfaces and abstract base classes that give you a write-side seam over a persistence layer: SaverInterface, RemoverInterface, BulkSaverInterface, BulkRemoverInterface, plus BaseSaver / BaseRemover implementations and a handful of StorageEvents constants.
The whole value proposition is: instead of calling $product->save() from twenty places, you inject SaverInterface and call save($product), which lets you swap persistence, attach cross-cutting listeners, and honour bulk semantics without touching call sites.
1. Confirm the namespace before anything else. Forks frequently rename. Open vendor/vimex/storage-utils/composer.json and read autoload.psr-4. Upstream is Akeneo\Tool\Component\StorageUtils\ — if that's what you see, the code below works verbatim.
2. Check the PHP constraint. This is a 2018 package; expect "php": "^7.0 || ^7.1" and Symfony 2.8/3.x components. On PHP 8.2+ you will likely need composer require vimex/storage-utils --ignore-platform-reqs and should still expect breakage. If you're on a modern stack, read the "Gotchas" section before committing to this dependency.
3. Look at three files, in this order:
Saver/SaverInterface.php — the contract (save($object, array $options = []))Saver/BaseSaver.php — how persisting and event dispatch actually happenStorageEvents.php — the PRE_SAVE / POST_SAVE / PRE_REMOVE / POST_REMOVE constantsEverything else in the package is a variation on those three.
4. Your first use case is almost always "make one aggregate's persistence explicit." Subclass BaseSaver for a single aggregate root and bind it in a service provider:
namespace App\Storage;
use Akeneo\Tool\Component\StorageUtils\Saver\BaseSaver;
final class ProductSaver extends BaseSaver
{
// BaseSaver does the persist + flush + event dispatch;
// override only if you need pre/post hooks around it.
}
// app/Providers/StorageUtilsServiceProvider.php
use Akeneo\Tool\Component\StorageUtils\Saver\SaverInterface;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
public function register(): void
{
// Keep this dispatcher isolated from Laravel's — see Gotchas.
$this->app->singleton(EventDispatcherInterface::class, fn () => new EventDispatcher());
$this->app->bind(SaverInterface::class, fn ($app) => new ProductSaver(
$app->make(\Doctrine\Persistence\ObjectManager::class), // laravel-doctrine
$app->make(EventDispatcherInterface::class),
'app.product'
));
}
Then call it from an action, never from a controller directly:
final class PublishProduct
{
public function __construct(private SaverInterface $saver) {}
public function __invoke(Product $product): void
{
$product->publish();
$this->saver->save($product, ['actor_id' => auth()->id()]);
}
}
That's the entire onboarding. There is no config file, no migration, no facade, no artisan command. If you were expecting a Laravel package with a service provider, note that you are writing it yourself.
One saver per aggregate root, bound to an interface. Do not build a generic EntitySaver that takes any model. The value of the seam is that ProductSaver can later gain product-specific behaviour (indexing, versioning, revision bumping) without any call site knowing. Name the constructor's third argument ($eventName) after the aggregate — app.product, app.order — because BaseSaver dispatches both a generic akeneo.storage.pre_save and an aggregate-specific <eventName>.pre_save-style event, and that's the hook you'll actually subscribe to.
Bulk saves come from import/export jobs, not from HTTP requests. BulkSaver is a thin loop over SaverInterface::save(). Use it in queue workers and CSV importers:
use Akeneo\Tool\Component\StorageUtils\Saver\BulkSaver;
final class ImportProductsJob implements ShouldQueue
{
public function __construct(
private SaverInterface $saver,
private BulkSaverInterface $bulkSaver,
) {}
public function handle(): void
{
DB::transaction(function () {
foreach (array_chunk($this->rows(), 500) as $chunk) {
$this->bulkSaver->saveAll($this->hydrate($chunk));
}
});
}
}
Bind BulkSaverInterface as new BulkSaver($app->make(SaverInterface::class)). Note the transaction wrapper is yours to add — the package does not open one.
Treat $options as an event payload, not as instructions. BaseSaver does not read the options array; it forwards it into the dispatched event. So save($obj, ['flush' => false]) does not skip the flush. Consumers use options for things like ['actor_id' => ..., 'source' => 'import'] so listeners can attribute the change. Document your option keys in the saver class itself, since there's no schema.
Bridge the two event systems at the boundary. Symfony's dispatcher and Laravel's dispatcher are separate. Two viable approaches:
Isolated dispatcher (recommended). Instantiate Symfony\Component\EventDispatcher\EventDispatcher as a singleton, register listeners on it directly, and don't try to unify:
$dispatcher->addListener('app.product.post_save', function (GenericEvent $event) {
IndexProduct::dispatch($event->getSubject()->getId());
});
Forwarding adapter. If you want Event::listen('app.product.post_save', ...) to work, write a small adapter implementing Symfony\Component\EventDispatcher\EventDispatcherInterface that delegates dispatch() to Illuminate\Contracts\Events\Dispatcher::dispatch(). Watch the argument-order change described in Gotchas.
Use listeners for cross-cutting write concerns, not for domain logic. Cache invalidation, search indexing, audit logging, and webhook fan-out are the sweet spot. Anything that must be atomic with the write belongs inside DB::transaction() in the caller, not in a POST_SAVE listener — by the time the listener runs, the flush has already happened.
Fake the interface in tests. This is the main practical payoff. Your unit tests never touch a database:
final class InMemorySaver implements SaverInterface
{
public array $saved = [];
public function save($object, array $options = []): void { $this->saved[] = [$object, $options]; }
}
Then assert $saver->saved[0][1]['actor_id'] === 42. For integration tests, use the real saver against a transaction-wrapped test database.
When the base classes don't fit, override save() entirely rather than fighting them. BaseSaver::save() is ~10 lines; copying it into your subclass and adding a version increment or an outbox write is a legitimate, low-cost extension. There is no configuration surface to extend.
This fork is a dead end — weigh it before adopting. Zero stars, zero dependents, last release 2018-06-20. The upstream akeneo/storage-utils is the same code with actual maintenance history. And the component is genuinely small: SaverInterface is two methods, BaseSaver is ten lines. For a Laravel app, writing your own SaverInterface/BaseSaver inside app/Storage/ costs about an hour and gives you zero dependency risk, Eloquent-native flush control, and Laravel-native events. Adopt the package only if you're already inside an Akeneo-adjacent codebase or need byte-compatibility with Akeneo's event names.
Doctrine coupling is the hard blocker. BaseSaver type-hints an ObjectManager. Eloquent's Model is not one. Your options:
composer require laravel-doctrine/orm and use Doctrine entities — the cleanest fit, but a large architectural commitment.Doctrine\Persistence\ObjectManager that translates persist() → $model->save() and flush() → no-op. Doable but the interface is wide (find, remove, getRepository, getClassMetadata, …); you'll be stubbing a lot.SaverInterface yourself with Eloquent inside. Usually the right answer.Watch for the Doctrine\Common\Persistence namespace. 2018 code imports Doctrine\Common\Persistence\ObjectManager, which was removed in doctrine/persistence 2.0 (moved to Doctrine\Persistence\ObjectManager). If you get "interface not found," either pin doctrine/persistence:^1.3 or patch the use statements.
Watch for the Symfony 4.3 event-dispatcher BC break. Old code calls $dispatcher->dispatch($eventName, $event); Symfony 4.3+ reordered to dispatch($event, $eventName = null). If you swap in a modern Symfony dispatcher, the base classes will silently fire events with the arguments inverted and nothing will break loudly — your listeners just never match. Keep the pinned event-dispatcher version, or patch the calls, or use an isolated old-version dispatcher instance.
Flush-per-save is an N+1 disaster. BaseSaver::save() calls persist() then flush() every time. A loop of 5,000 save() calls is 5,000 round trips (or 5,000 UPDATE statements under Eloquent). BulkSaver does not fix this — it loops save(). If throughput matters, subclass and add a deferred-flush mode, or wrap the loop in a transaction and accept the statement count, or bypass the abstraction for bulk paths.
BulkSaver does not deduplicate. Passing the same object twice saves it twice and dispatches PRE_SAVE/POST_SAVE twice. Deduplicate with spl_object_id() (or your identifier) before calling saveAll().
No validation on the options array. There is no OptionsResolver in the base classes, so ['flush' => false] typos are silently ignored and never surface as errors. If you rely on options in listeners, assert on required keys inside the listener and throw loudly.
PRE_SAVE fires, then flush() may throw — and POST_SAVE never fires. Listeners attached to PRE_SAVE must be idempotent and must not assume the write succeeded. Conversely, don't put compensating logic in PRE_SAVE — you won't know to run it.
Nothing is transactional. The package never opens a transaction. Two save() calls with a listener in between can leave you half-written. Always wrap multi-object writes in DB::transaction() (or Doctrine's transactional()) at the caller level.
Debugging tip: when a listener doesn't fire, dump $dispatcher->getListeners() from the container to confirm you're resolving the same dispatcher singleton the saver received. Binding EventDispatcherInterface as bind() instead of singleton() gives every resolution a fresh instance with no listeners — a classic five-minute mystery.
Extension points, exhaustively: subclass BaseSaver/BaseRemover, override save()/remove(), override getEventName()-style behaviour if present in your version, and register listeners on the aggregate-specific event. There is no plugin system, no tagged-service collection, no strategy registry. If you need more than that, you've outgrown the package.
How can I help you explore Laravel packages today?