From ca48f418056bdeba263f6340b8c247ad0872e470 Mon Sep 17 00:00:00 2001 From: Mathias Grimm Date: Fri, 26 Jun 2026 15:11:34 -0300 Subject: [PATCH 1/2] Add SelfHandlingOptimizer for binary-less optimizers Some optimizers have no binary and no shell command, for example one that sends the image to an external optimization API. Add a SelfHandlingOptimizer interface (extending Optimizer) with a handle(Image, LoggerInterface) method; the chain delegates execution to it instead of building and running a Process, passing the chain's logger so the optimizer can log its own progress. The change is fully additive: SelfHandlingOptimizer extends Optimizer so instances still satisfy every existing type hint, and OptimizerChain only gains a branch inside runOptimizer()'s body, with no method signatures changed (subclasses overriding the protected methods stay compatible). Failures flow through the existing throws() handling from the previous release, and a failing handle() is logged like a failing binary. A BaseSelfHandlingOptimizer helper lets implementers write only canHandle() and handle(). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 29 +++ src/OptimizerChain.php | 16 ++ src/Optimizers/BaseSelfHandlingOptimizer.php | 63 +++++ src/SelfHandlingOptimizer.php | 23 ++ tests/SelfHandlingOptimizerTest.php | 248 +++++++++++++++++++ 5 files changed, 379 insertions(+) create mode 100644 src/Optimizers/BaseSelfHandlingOptimizer.php create mode 100644 src/SelfHandlingOptimizer.php create mode 100644 tests/SelfHandlingOptimizerTest.php diff --git a/README.md b/README.md index 17288a1..4bd23df 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,35 @@ $optimizerChain ->optimize($pathToImage); ``` +### Writing an optimizer without a binary + +Sometimes an optimizer has no binary and no shell command to run, for example one that sends the image to an external optimization API. For these cases implement `Spatie\ImageOptimizer\SelfHandlingOptimizer` instead. The chain delegates execution to your `handle` method rather than building and running a process. + +The easiest way is to extend `Spatie\ImageOptimizer\Optimizers\BaseSelfHandlingOptimizer`, which leaves you to implement only `canHandle` and `handle`: + +```php +use Psr\Log\LoggerInterface; +use Spatie\ImageOptimizer\Image; +use Spatie\ImageOptimizer\Optimizers\BaseSelfHandlingOptimizer; + +class ApiOptimizer extends BaseSelfHandlingOptimizer +{ + public function canHandle(Image $image): bool + { + return $image->mime() === 'image/jpeg'; + } + + public function handle(Image $image, LoggerInterface $logger): void + { + // Optimize $image->path() however you like, e.g. by calling an API, + // and write the optimized bytes back to that path. Throw on failure. + // The chain's logger is passed in so you can log your progress. + } +} +``` + +Add it to a chain with `addOptimizer()` just like any other optimizer. Failures are governed by [`throws`](#handling-errors) in exactly the same way as binary optimizers: by default the failure is logged and the chain continues, while `throws()` (or a callable) lets you abort or handle it. + ## Logging the optimization process By default the package will not throw any errors and just operate silently. To verify what the package is doing you can set a logger: diff --git a/src/OptimizerChain.php b/src/OptimizerChain.php index 7c6db9b..b7c6a58 100644 --- a/src/OptimizerChain.php +++ b/src/OptimizerChain.php @@ -150,6 +150,22 @@ protected function applyOptimizer(Optimizer $optimizer, Image $image) protected function runOptimizer(Optimizer $optimizer, Image $image) { + if ($optimizer instanceof SelfHandlingOptimizer) { + $className = get_class($optimizer); + + $this->logger->info("Executing `{$className}`"); + + try { + $optimizer->handle($image, $this->logger); + } catch (Throwable $exception) { + $this->logger->error("Optimizer errored with `{$exception->getMessage()}`"); + + throw $exception; + } + + return; + } + $command = $optimizer->getCommand(); $this->logger->info("Executing `{$command}`"); diff --git a/src/Optimizers/BaseSelfHandlingOptimizer.php b/src/Optimizers/BaseSelfHandlingOptimizer.php new file mode 100644 index 0000000..335109f --- /dev/null +++ b/src/Optimizers/BaseSelfHandlingOptimizer.php @@ -0,0 +1,63 @@ +setOptions($options); + } + + public function setImagePath(string $imagePath) + { + $this->imagePath = $imagePath; + + return $this; + } + + public function setOptions(array $options = []) + { + $this->options = $options; + + return $this; + } + + public function getTmpPath(): ?string + { + return $this->tmpPath; + } + + /* + * Only exists to satisfy the inherited Optimizer contract. A self-handling + * optimizer has no binary, so this is never called. + */ + public function binaryName(): string + { + return ''; + } + + /* + * Only exists to satisfy the inherited Optimizer contract. A self-handling + * optimizer has no command: the chain delegates to handle() instead, so this + * is never called. + */ + public function getCommand(): string + { + return ''; + } + + abstract public function canHandle(Image $image): bool; + + abstract public function handle(Image $image, LoggerInterface $logger): void; +} diff --git a/src/SelfHandlingOptimizer.php b/src/SelfHandlingOptimizer.php new file mode 100644 index 0000000..8d60a98 --- /dev/null +++ b/src/SelfHandlingOptimizer.php @@ -0,0 +1,23 @@ +ranWith = $image; + $this->runCount++; + + $logger->info('Optimizing via API'); + } +} + +/** + * A self-handling optimizer whose handle() throws, to exercise the chain's failure + * handling for the commandless path. + */ +class ThrowingSelfHandlingOptimizer extends BaseSelfHandlingOptimizer +{ + public function canHandle(Image $image): bool + { + return true; + } + + public function handle(Image $image, LoggerInterface $logger): void + { + throw new RuntimeException('self-handling boom'); + } +} + +/** + * A self-handling optimizer that never handles the image, to assert handle() is skipped. + */ +class NonHandlingSelfHandlingOptimizer extends BaseSelfHandlingOptimizer +{ + public $runCount = 0; + + public function canHandle(Image $image): bool + { + return false; + } + + public function handle(Image $image, LoggerInterface $logger): void + { + $this->runCount++; + } +} + +/** + * A self-handling optimizer that exposes a real temp file so we can assert the chain + * cleans it up after handle(), even when handle() throws. + */ +class TmpFileSelfHandlingOptimizer extends BaseSelfHandlingOptimizer +{ + public $shouldThrow = false; + + public function canHandle(Image $image): bool + { + return true; + } + + public function handle(Image $image, LoggerInterface $logger): void + { + if ($this->shouldThrow) { + throw new RuntimeException('self-handling boom'); + } + } +} + +/** + * A plain binary-style optimizer running a command that exits zero, used to + * assert the binary optimizer flow is unchanged when mixed with a self-handling optimizer. + */ +class SucceedingBinaryOptimizer implements Optimizer +{ + public function binaryName(): string + { + return 'true'; + } + + public function canHandle(Image $image): bool + { + return true; + } + + public function setImagePath(string $imagePath) + { + return $this; + } + + public function setOptions(array $options = []) + { + return $this; + } + + public function getCommand(): string + { + return 'true'; + } + + public function getTmpPath(): ?string + { + return null; + } +} + +beforeEach(function () { + $this->testImage = getTempFilePath('image.jpg'); + + $this->optimizerChain = (new OptimizerChain())->useLogger($this->log); +}); + +it('delegates execution to a self-handling optimizer and passes it the image', function () { + $optimizer = new RecordingSelfHandlingOptimizer(); + + $this + ->optimizerChain + ->setOptimizers([$optimizer]) + ->optimize($this->testImage); + + expect($optimizer->runCount)->toBe(1); + expect($optimizer->ranWith)->toBeInstanceOf(Image::class); + expect($optimizer->ranWith->path())->toBe($this->testImage); + + expect($this->log->getAllLinesAsString()) + ->toContain('Using optimizer: `RecordingSelfHandlingOptimizer`') + ->toContain('Executing `RecordingSelfHandlingOptimizer`') + ->toContain('Optimizing via API'); +}); + +it('does not run a self-handling optimizer that cannot handle the image', function () { + $optimizer = new NonHandlingSelfHandlingOptimizer(); + + $this + ->optimizerChain + ->setOptimizers([$optimizer]) + ->optimize($this->testImage); + + expect($optimizer->runCount)->toBe(0); + + expect($this->log->getAllLinesAsString()) + ->not->toContain('Using optimizer: `NonHandlingSelfHandlingOptimizer`'); +}); + +it('does not throw by default when a self-handling optimizer fails', function () { + $this + ->optimizerChain + ->setOptimizers([new ThrowingSelfHandlingOptimizer(), new RecordingSelfHandlingOptimizer()]) + ->optimize($this->testImage); + + expect($this->log->getAllLinesAsString()) + ->toContain('Using optimizer: `ThrowingSelfHandlingOptimizer`') + ->toContain('error: Optimizer errored with `self-handling boom`') + ->toContain('Using optimizer: `RecordingSelfHandlingOptimizer`'); +}); + +it('aborts the chain when a self-handling optimizer fails and throws() is enabled', function () { + $this + ->optimizerChain + ->throws() + ->setOptimizers([new ThrowingSelfHandlingOptimizer(), new RecordingSelfHandlingOptimizer()]); + + expect(fn () => $this->optimizerChain->optimize($this->testImage)) + ->toThrow(RuntimeException::class, 'self-handling boom'); + + expect($this->log->getAllLinesAsString()) + ->not->toContain('Using optimizer: `RecordingSelfHandlingOptimizer`'); +}); + +it('routes a self-handling optimizer failure to a custom handler', function () { + $captured = []; + + $this + ->optimizerChain + ->throws(function ($exception, $optimizer, $image) use (&$captured) { + $captured = [$exception, $optimizer, $image]; + }) + ->setOptimizers([new ThrowingSelfHandlingOptimizer()]) + ->optimize($this->testImage); + + expect($captured[0])->toBeInstanceOf(RuntimeException::class); + expect($captured[1])->toBeInstanceOf(ThrowingSelfHandlingOptimizer::class); + expect($captured[2])->toBeInstanceOf(Image::class); +}); + +it('cleans up a self-handling optimizer temp file after running', function () { + $optimizer = new TmpFileSelfHandlingOptimizer(); + $optimizer->tmpPath = tempnam(sys_get_temp_dir(), 'selfhandling'); + + expect(file_exists($optimizer->tmpPath))->toBeTrue(); + + $this + ->optimizerChain + ->setOptimizers([$optimizer]) + ->optimize($this->testImage); + + expect(file_exists($optimizer->tmpPath))->toBeFalse(); +}); + +it('cleans up a self-handling optimizer temp file even when handle() throws', function () { + $optimizer = new TmpFileSelfHandlingOptimizer(); + $optimizer->shouldThrow = true; + $optimizer->tmpPath = tempnam(sys_get_temp_dir(), 'selfhandling'); + + expect(file_exists($optimizer->tmpPath))->toBeTrue(); + + $this + ->optimizerChain + ->setOptimizers([$optimizer]) + ->optimize($this->testImage); + + expect(file_exists($optimizer->tmpPath))->toBeFalse(); +}); + +it('runs binary and self-handling optimizers together, leaving the binary optimizer flow unchanged', function () { + $selfHandling = new RecordingSelfHandlingOptimizer(); + + $this + ->optimizerChain + ->setOptimizers([new SucceedingBinaryOptimizer(), $selfHandling]) + ->optimize($this->testImage); + + expect($selfHandling->runCount)->toBe(1); + + expect($this->log->getAllLinesAsString()) + ->toContain('Using optimizer: `SucceedingBinaryOptimizer`') + ->toContain('Executing `true`') + ->toContain('Using optimizer: `RecordingSelfHandlingOptimizer`'); +}); From 6ae5c749efa7772dae2afd10851efe470f70dc49 Mon Sep 17 00:00:00 2001 From: Freek Van der Herten Date: Mon, 29 Jun 2026 10:01:39 +0200 Subject: [PATCH 2/2] Extend BaseOptimizer to remove duplicated plumbing BaseSelfHandlingOptimizer reimplemented the options, image path and tmp path state, the constructor and their accessors byte-for-byte from BaseOptimizer. Extend BaseOptimizer instead and keep only the no-op binaryName()/getCommand() overrides and the abstract handle(). --- src/Optimizers/BaseSelfHandlingOptimizer.php | 44 ++------------------ 1 file changed, 4 insertions(+), 40 deletions(-) diff --git a/src/Optimizers/BaseSelfHandlingOptimizer.php b/src/Optimizers/BaseSelfHandlingOptimizer.php index 335109f..fc423f4 100644 --- a/src/Optimizers/BaseSelfHandlingOptimizer.php +++ b/src/Optimizers/BaseSelfHandlingOptimizer.php @@ -6,58 +6,22 @@ use Spatie\ImageOptimizer\Image; use Spatie\ImageOptimizer\SelfHandlingOptimizer; -abstract class BaseSelfHandlingOptimizer implements SelfHandlingOptimizer +abstract class BaseSelfHandlingOptimizer extends BaseOptimizer implements SelfHandlingOptimizer { - public $options = []; - - public $imagePath = ''; - - public $tmpPath = null; - - public function __construct($options = []) - { - $this->setOptions($options); - } - - public function setImagePath(string $imagePath) - { - $this->imagePath = $imagePath; - - return $this; - } - - public function setOptions(array $options = []) - { - $this->options = $options; - - return $this; - } - - public function getTmpPath(): ?string - { - return $this->tmpPath; - } - /* - * Only exists to satisfy the inherited Optimizer contract. A self-handling - * optimizer has no binary, so this is never called. + * A self-handling optimizer has no binary, so these only exist to satisfy the + * inherited Optimizer contract. The chain delegates to handle() before they + * would ever be called. */ public function binaryName(): string { return ''; } - /* - * Only exists to satisfy the inherited Optimizer contract. A self-handling - * optimizer has no command: the chain delegates to handle() instead, so this - * is never called. - */ public function getCommand(): string { return ''; } - abstract public function canHandle(Image $image): bool; - abstract public function handle(Image $image, LoggerInterface $logger): void; }