• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

plank / laravel-mediable / 23991546572

05 Apr 2026 01:22AM UTC coverage: 94.27% (-0.5%) from 94.72%
23991546572

push

github

web-flow
Add support for intervention/image:4.0 (#388)

- Added support for intervention/image:4.0 (#388)
- Dropped support for intervention/image:2.X
- Added ImageManipulation::setOutputOptions(), to support various intervention/image output format settings
- Deprecated ImageManipulation::setOutputQuality(). Use the 'quality' key in setOutputOptions() instead

29 of 38 new or added lines in 2 files covered. (76.32%)

3 existing lines in 1 file now uncovered.

1497 of 1588 relevant lines covered (94.27%)

152.14 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.43
/src/ImageManipulator.php
1
<?php
2

3
namespace Plank\Mediable;
4

5
use GuzzleHttp\Psr7\Utils;
6
use Illuminate\Filesystem\FilesystemManager;
7
use Illuminate\Support\Collection;
8
use Intervention\Image\Commands\StreamCommand;
9
use Intervention\Image\Format;
10
use Intervention\Image\Image;
11
use Intervention\Image\ImageManager;
12
use Plank\Mediable\Exceptions\ImageManipulationException;
13
use Plank\Mediable\Exceptions\MediaUpload\ConfigurationException;
14
use Plank\Mediable\SourceAdapters\SourceAdapterInterface;
15
use Plank\Mediable\SourceAdapters\StreamAdapter;
16
use Psr\Http\Message\StreamInterface;
17
use Spatie\ImageOptimizer\OptimizerChain;
18

19
class ImageManipulator
20
{
21
    private ?ImageManager $imageManager;
22

23
    /**
24
     * @var ImageManipulation[]
25
     */
26
    private array $variantDefinitions = [];
27

28
    private array $variantDefinitionGroups = [];
29

30
    /**
31
     * @var FilesystemManager
32
     */
33
    private $filesystem;
34

35
    private ImageOptimizer $imageOptimizer;
36

37
    public function __construct(
38
        ?ImageManager $imageManager,
39
        FilesystemManager $filesystem,
40
        ImageOptimizer $imageOptimizer
41
    ) {
42
        $this->imageManager = $imageManager;
954✔
43
        $this->filesystem = $filesystem;
954✔
44
        $this->imageOptimizer = $imageOptimizer;
954✔
45
    }
46

47
    public function defineVariant(
48
        string $variantName,
49
        ImageManipulation $manipulation,
50
        ?array $tags = []
51
    ) {
52
        if (!$this->imageManager) {
174✔
53
            throw ConfigurationException::interventionImageNotConfigured();
6✔
54
        }
55
        $this->variantDefinitions[$variantName] = $manipulation;
168✔
56
        foreach ($tags as $tag) {
168✔
57
            $this->variantDefinitionGroups[$tag][] = $variantName;
6✔
58
        }
59
    }
60

61
    public function hasVariantDefinition(string $variantName): bool
62
    {
63
        return isset($this->variantDefinitions[$variantName]);
6✔
64
    }
65

66
    /**
67
     * @param string $variantName
68
     * @return ImageManipulation
69
     * @throws ImageManipulationException if Variant is not defined
70
     */
71
    public function getVariantDefinition(string $variantName): ImageManipulation
72
    {
73
        if (isset($this->variantDefinitions[$variantName])) {
150✔
74
            return $this->variantDefinitions[$variantName];
144✔
75
        }
76

77
        throw ImageManipulationException::unknownVariant($variantName);
6✔
78
    }
79

80
    public function getAllVariantDefinitions(): Collection
81
    {
82
        return collect($this->variantDefinitions);
12✔
83
    }
84

85
    public function getAllVariantNames(): array
86
    {
87
        return array_keys($this->variantDefinitions);
6✔
88
    }
89

90
    public function getVariantDefinitionsByTag(string $tag): Collection
91
    {
92
        return $this->getAllVariantDefinitions()
6✔
93
            ->intersectByKeys(array_flip($this->getVariantNamesByTag($tag)));
6✔
94
    }
95

96
    public function getVariantNamesByTag(string $tag): array
97
    {
98
        return $this->variantDefinitionGroups[$tag] ?? [];
6✔
99
    }
100

101
    /**
102
     * @param Media $media
103
     * @param string $variantName
104
     * @param bool $forceRecreate
105
     * @return Media
106
     * @throws ImageManipulationException
107
     */
108
    public function createImageVariant(
109
        Media $media,
110
        string $variantName,
111
        bool $forceRecreate = false
112
    ): Media {
113
        if (!$this->imageManager) {
162✔
114
            throw ConfigurationException::interventionImageNotConfigured();
6✔
115
        }
116

117
        $this->validateMedia($media);
156✔
118

119
        $modelClass = config('mediable.model');
150✔
120
        /** @var Media $variant */
121
        $variant = new $modelClass();
150✔
122
        $recreating = false;
150✔
123
        $originalVariant = null;
150✔
124

125
        // don't recreate if that variant already exists for the model
126
        if ($media->hasVariant($variantName)) {
150✔
127
            $variant = $media->findVariant($variantName);
18✔
128
            if ($forceRecreate) {
18✔
129
                // replace the existing variant
130
                $recreating = true;
12✔
131
                $originalVariant = clone $variant;
12✔
132
            } else {
133
                // variant already exists, nothing more to do
134
                return $variant;
6✔
135
            }
136
        }
137

138
        $manipulation = $this->getVariantDefinition($variantName);
144✔
139

140
        $outputFormat = $this->determineOutputFormat($manipulation, $media);
138✔
141
        if (method_exists($this->imageManager, 'decode')) {
132✔
142
            // Intervention Image  >=4.0
143
            $image = $this->imageManager->decode($media->contents());
66✔
144
        } else {
145
            // Intervention Image ~3.0
146
            $image = $this->imageManager->read($media->contents());
66✔
147
        }
148

149
        $callback = $manipulation->getCallback();
132✔
150
        $callback($image, $media);
132✔
151

152
        $outputStream = $this->imageToStream(
132✔
153
            $image,
132✔
154
            $outputFormat,
132✔
155
            $manipulation->getOutputOptions()
132✔
156
        );
132✔
157

158
        if ($manipulation->shouldOptimize()) {
132✔
159
            $outputStream = $this->imageOptimizer->optimizeImage(
6✔
160
                $outputStream,
6✔
161
                $manipulation->getOptimizerChain()
6✔
162
            );
6✔
163
        }
164

165
        $variant->variant_name = $variantName;
132✔
166
        $variant->original_media_id = $media->isOriginal()
132✔
167
            ? $media->getKey()
126✔
168
            : $media->original_media_id; // attach variants of variants to the same original
12✔
169

170
        $variant->disk = $manipulation->getDisk() ?? $media->disk;
132✔
171
        $variant->directory = $manipulation->getDirectory() ?? $media->directory;
132✔
172
        $variant->filename = $this->determineFilename(
132✔
173
            $media->findOriginal(),
132✔
174
            $manipulation,
132✔
175
            $variant,
132✔
176
            $outputStream
132✔
177
        );
132✔
178
        $variant->extension = $outputFormat;
132✔
179
        $variant->mime_type = $this->getMimeTypeForOutputFormat($outputFormat);
132✔
180
        $variant->aggregate_type = Media::TYPE_IMAGE;
132✔
181
        $variant->size = $outputStream->getSize();
132✔
182

183
        $this->checkForDuplicates($variant, $manipulation, $originalVariant);
132✔
184
        if ($beforeSave = $manipulation->getBeforeSave()) {
126✔
185
            $beforeSave($variant, $originalVariant);
18✔
186
            // destination may have been changed, check for duplicates again
187
            $this->checkForDuplicates($variant, $manipulation, $originalVariant);
18✔
188
        }
189

190
        if ($recreating) {
120✔
191
            // delete the original file for that variant
192
            $this->filesystem->disk($originalVariant->disk)
12✔
193
                ->delete($originalVariant->getDiskPath());
12✔
194
        }
195

196
        $visibility = $manipulation->getVisibility();
120✔
197
        if ($visibility == 'match') {
120✔
198
            $visibility = ($media->isVisible() ? 'public' : 'private');
12✔
199
        }
200
        $options = [];
120✔
201
        if ($visibility) {
120✔
202
            $options = ['visibility' => $visibility];
24✔
203
        }
204

205
        $this->filesystem->disk($variant->disk)
120✔
206
            ->writeStream(
120✔
207
                $variant->getDiskPath(),
120✔
208
                $outputStream->detach(),
120✔
209
                $options
120✔
210
            );
120✔
211

212
        $variant->save();
120✔
213

214
        return $variant;
120✔
215
    }
216

217
    /**
218
     * @param Media $media
219
     * @param SourceAdapterInterface $source
220
     * @param ImageManipulation $manipulation
221
     * @return StreamAdapter
222
     * @throws ImageManipulationException
223
     */
224
    public function manipulateUpload(
225
        Media $media,
226
        SourceAdapterInterface $source,
227
        ImageManipulation $manipulation
228
    ): StreamAdapter {
229
        if (!$this->imageManager) {
12✔
230
            throw ConfigurationException::interventionImageNotConfigured();
6✔
231
        }
232

233
        $outputFormat = $this->determineOutputFormat($manipulation, $media);
6✔
234
        if (method_exists($this->imageManager, 'decode')) {
6✔
235
            // Intervention Image  >=4.0
236
            $image = $this->imageManager->decode($source->getStream()->getContents());
3✔
237
        } else {
238
            // Intervention Image ~3.0
239
            $image = $this->imageManager->read($source->getStream()->getContents());
3✔
240
        }
241

242
        $callback = $manipulation->getCallback();
6✔
243
        $callback($image, $media);
6✔
244

245
        $outputStream = $this->imageToStream(
6✔
246
            $image,
6✔
247
            $outputFormat,
6✔
248
            $manipulation->getOutputOptions()
6✔
249
        );
6✔
250

251
        if ($manipulation->shouldOptimize()) {
6✔
252
            $outputStream = $this->imageOptimizer->optimizeImage(
×
253
                $outputStream,
×
254
                $manipulation->getOptimizerChain()
×
255
            );
×
256
        }
257

258
        $media->extension = $outputFormat;
6✔
259
        $media->mime_type = $this->getMimeTypeForOutputFormat($outputFormat);
6✔
260
        $media->aggregate_type = Media::TYPE_IMAGE;
6✔
261
        $media->size = $outputStream->getSize();
6✔
262

263
        return new StreamAdapter($outputStream);
6✔
264
    }
265

266
    private function getMimeTypeForOutputFormat(string $outputFormat): string
267
    {
268
        return ImageManipulation::MIME_TYPE_MAP[$outputFormat];
138✔
269
    }
270

271
    /**
272
     * @param ImageManipulation $manipulation
273
     * @param Media $media
274
     * @return string
275
     * @throws ImageManipulationException If output format cannot be determined
276
     */
277
    private function determineOutputFormat(
278
        ImageManipulation $manipulation,
279
        Media $media
280
    ): string {
281
        if ($format = $manipulation->getOutputFormat()) {
144✔
282
            return $format;
30✔
283
        }
284

285
        // attempt to infer the format from the mime type
286
        $mime = strtolower($media->mime_type);
114✔
287
        $format = array_search($mime, ImageManipulation::MIME_TYPE_MAP);
114✔
288
        if ($format !== false) {
114✔
289
            return $format;
108✔
290
        }
291

292
        // attempt to infer the format from the file extension
293
        $extension = strtolower($media->extension);
6✔
294
        if (in_array($extension, ImageManipulation::VALID_IMAGE_FORMATS)) {
6✔
295
            return $extension;
×
296
        }
297
        if ($extension === 'jpeg') {
6✔
298
            return ImageManipulation::FORMAT_JPG;
×
299
        }
300
        if ($extension === 'tiff') {
6✔
301
            return ImageManipulation::FORMAT_TIFF;
×
302
        }
303

304
        throw ImageManipulationException::unknownOutputFormat();
6✔
305
    }
306

307
    public function determineFilename(
308
        Media $originalMedia,
309
        ImageManipulation $manipulation,
310
        Media $variant,
311
        StreamInterface $stream
312
    ): string {
313
        if ($filename = $manipulation->getFilename()) {
132✔
314
            return $filename;
6✔
315
        }
316

317
        if ($manipulation->isUsingHashForFilename()) {
126✔
318
            return $this->getHashFromStream(
6✔
319
                $stream,
6✔
320
                $manipulation->getHashFilenameAlgo() ?? 'md5'
6✔
321
            );
6✔
322
        }
323
        return sprintf('%s-%s', $originalMedia->filename, $variant->variant_name);
120✔
324
    }
325

326
    public function validateMedia(Media $media): void
327
    {
328
        if ($media->aggregate_type != Media::TYPE_IMAGE) {
156✔
329
            throw ImageManipulationException::invalidMediaType($media->aggregate_type);
6✔
330
        }
331
    }
332

333
    private function getHashFromStream(StreamInterface $stream, string $algo): string
334
    {
335
        $stream->rewind();
6✔
336
        $hash = hash_init($algo);
6✔
337
        while ($chunk = $stream->read(2048)) {
6✔
338
            hash_update($hash, $chunk);
6✔
339
        }
340
        $filename = hash_final($hash);
6✔
341
        $stream->rewind();
6✔
342

343
        return $filename;
6✔
344
    }
345

346
    private function checkForDuplicates(
347
        Media $variant,
348
        ImageManipulation $manipulation,
349
        ?Media $originalVariant = null
350
    ) {
351
        if ($originalVariant
132✔
352
            && $variant->disk === $originalVariant->disk
132✔
353
            && $variant->getDiskPath() === $originalVariant->getDiskPath()
132✔
354
        ) {
355
            // same as the original, no conflict as we are going to replace the file anyways
356
            return;
6✔
357
        }
358

359
        if (!$this->filesystem->disk($variant->disk)->exists($variant->getDiskPath())) {
132✔
360
            // no conflict, carry on
361
            return;
120✔
362
        }
363

364
        switch ($manipulation->getOnDuplicateBehaviour()) {
24✔
365
            case ImageManipulation::ON_DUPLICATE_ERROR:
24✔
366
                throw ImageManipulationException::fileExists($variant->getDiskPath());
12✔
367

368
            case ImageManipulation::ON_DUPLICATE_INCREMENT:
12✔
369
            default:
370
                $variant->filename = $this->generateUniqueFilename($variant);
12✔
371
                break;
12✔
372
        }
373
    }
374

375
    /**
376
     * Increment model's filename until one is found that doesn't already exist.
377
     * @param Media $model
378
     * @return string
379
     */
380
    private function generateUniqueFilename(Media $model): string
381
    {
382
        $storage = $this->filesystem->disk($model->disk);
12✔
383
        $counter = 0;
12✔
384
        do {
385
            $filename = "{$model->filename}";
12✔
386
            if ($counter > 0) {
12✔
387
                $filename .= '-' . $counter;
12✔
388
            }
389
            $path = "{$model->directory}/{$filename}.{$model->extension}";
12✔
390
            ++$counter;
12✔
391
        } while ($storage->exists($path));
12✔
392

393
        return $filename;
12✔
394
    }
395

396
    private function imageToStream(
397
        Image $image,
398
        string $outputFormat,
399
        array $outputOptions
400
    ) {
401
        if (method_exists($image, 'encodeUsingFormat')) {
138✔
402
            // Intervention Image  >=4.0
403

404
            $formatted = $image->encodeUsingFormat(
69✔
405
                match ($outputFormat) {
69✔
406
                    ImageManipulation::FORMAT_JPG => Format::JPEG,
69✔
407
                    ImageManipulation::FORMAT_PNG => Format::PNG,
60✔
408
                    ImageManipulation::FORMAT_GIF => Format::GIF,
3✔
NEW
409
                    ImageManipulation::FORMAT_WEBP => Format::WEBP,
×
NEW
410
                    ImageManipulation::FORMAT_TIFF => Format::TIFF,
×
NEW
411
                    ImageManipulation::FORMAT_HEIC => Format::HEIC,
×
412
                    default => throw ImageManipulationException::unknownOutputFormat(),
69✔
413
                },
69✔
414
                ...$outputOptions
69✔
415
            );
69✔
416
            return Utils::streamFor($formatted->toStream());
69✔
417
        } else {
418
            // Intervention Image <4.0
419
            $outputQuality = $outputOptions['quality'] ?? 90;
69✔
420

421
            $formatted = match ($outputFormat) {
69✔
422
                ImageManipulation::FORMAT_JPG => $image->toJpeg($outputQuality),
69✔
423
                ImageManipulation::FORMAT_PNG => $image->toPng(),
60✔
424
                ImageManipulation::FORMAT_GIF => $image->toGif(),
3✔
NEW
425
                ImageManipulation::FORMAT_WEBP => $image->toWebp($outputQuality),
×
NEW
426
                ImageManipulation::FORMAT_TIFF => $image->toTiff($outputQuality),
×
NEW
427
                ImageManipulation::FORMAT_HEIC => $image->toHeic($outputQuality),
×
NEW
428
                default => throw ImageManipulationException::unknownOutputFormat(),
×
429
            };
69✔
430
            return Utils::streamFor($formatted->toFilePointer());
69✔
431
        }
432
    }
433
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc