Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
279e574
Adds image `optimization`
nunomaduro Mar 16, 2026
a340f25
Apply fixes from StyleCI
StyleCIBot Mar 16, 2026
5a876a8
Adds `cloudflare` driver
nunomaduro Mar 18, 2026
2dc031e
Add scale, orient, blur, and greyscale options
nunomaduro Mar 18, 2026
ed6e30e
Adjusts API
nunomaduro Mar 19, 2026
84acf8a
Fixes extension used
nunomaduro Mar 19, 2026
df28847
Refactors so image is immutable
nunomaduro Mar 19, 2026
0b92669
fixes
nunomaduro Mar 19, 2026
f8f8de3
Apply fixes from StyleCI
StyleCIBot Mar 19, 2026
c2563b3
Fixes cloudflare
nunomaduro Mar 23, 2026
da6a0d8
Refactors format
nunomaduro Mar 23, 2026
8a1ab59
Removes `avif`
nunomaduro Mar 23, 2026
3415acf
Fixes just asking format on cloudflare
nunomaduro Mar 23, 2026
3d61e0f
Removes `png` and `gif`
nunomaduro Mar 23, 2026
efa764f
Fixes cloudflare doing weird transformations
nunomaduro Mar 23, 2026
fe1c4d0
Various fixes
nunomaduro Mar 23, 2026
232c436
Apply fixes from StyleCI
StyleCIBot Mar 23, 2026
681ff1c
More fixes
nunomaduro Mar 23, 2026
95468c1
exceptions
nunomaduro Mar 24, 2026
4f49c01
validates input only
nunomaduro Mar 24, 2026
aaf7e8a
Adds `flip`, `flop` and `scale`, and fixes queues
nunomaduro Mar 24, 2026
0445ad8
fixes queues
nunomaduro Mar 24, 2026
d54addb
More tests
nunomaduro Mar 24, 2026
3579247
fix: GD / Imagick auto orientation
nunomaduro Mar 24, 2026
240af8e
fix
nunomaduro Mar 24, 2026
e54be3e
fix types
nunomaduro Mar 24, 2026
3c22b3d
fix: cloudflare name
nunomaduro Mar 25, 2026
8e94ab5
Clean up
nunomaduro Mar 25, 2026
3113d75
adds work in progress regarding purge
nunomaduro Mar 25, 2026
c0a909a
purge delete
nunomaduro Mar 25, 2026
001f882
Clear naming
nunomaduro Mar 25, 2026
dd1563e
qwd
nunomaduro Mar 25, 2026
6b8c1f6
adjustments
nunomaduro Mar 25, 2026
260230a
qwdqwd
nunomaduro Mar 25, 2026
6b02841
style
nunomaduro Mar 25, 2026
6a021fe
config changes
nunomaduro Mar 25, 2026
a994693
More tests
nunomaduro Mar 25, 2026
21cf5da
fix
nunomaduro Mar 25, 2026
24bc78b
message
nunomaduro Mar 25, 2026
1d88347
fix
nunomaduro Mar 25, 2026
79389a9
missing types
nunomaduro Mar 25, 2026
fac83ad
Removes non needed comments
nunomaduro Mar 25, 2026
d8dda49
Missing tests
nunomaduro Mar 25, 2026
bd7f089
add previous
nunomaduro Mar 25, 2026
0de2844
tests
nunomaduro Mar 25, 2026
4bd5bd3
More tests
nunomaduro Mar 25, 2026
0a10411
and more
nunomaduro Mar 25, 2026
0fca464
allow to specify the driver on pruneOrphaned
nunomaduro Mar 25, 2026
0da75d1
missing facade
nunomaduro Mar 25, 2026
bfc9810
fix
nunomaduro Mar 25, 2026
f35ac15
Apply fixes from StyleCI
StyleCIBot Mar 25, 2026
f22438b
to string
nunomaduro Mar 25, 2026
8b7b9fb
Merge branch 'feat/image' of github.com:laravel/framework into feat/i…
nunomaduro Mar 25, 2026
77db75f
missing import
nunomaduro Mar 25, 2026
266b8e9
Apply fixes from StyleCI
StyleCIBot Mar 25, 2026
18ce637
more str changes
nunomaduro Mar 25, 2026
f9dd0da
Merge branch 'feat/image' of github.com:laravel/framework into feat/i…
nunomaduro Mar 25, 2026
463719e
memory optimizations
nunomaduro Mar 25, 2026
31d8830
php does not support types const
nunomaduro Mar 25, 2026
6000b92
style
nunomaduro Mar 25, 2026
7c90632
fix tests
nunomaduro Mar 25, 2026
9e0d047
fix
nunomaduro Mar 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"aws/aws-sdk-php": "^3.322.9",
"fakerphp/faker": "^1.24",
"guzzlehttp/psr7": "^2.4",
"intervention/image": "^3.11.7",
"laravel/pint": "^1.18",
"league/flysystem-aws-s3-v3": "^3.25.1",
"league/flysystem-ftp": "^3.25.1",
Expand Down Expand Up @@ -143,6 +144,7 @@
"ext-fileinfo": "Required to use the Filesystem class.",
"ext-ftp": "Required to use the Flysystem FTP driver.",
"ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().",
"intervention/image": "Required to use the image processing features (^3.11.7).",
"ext-memcached": "Required to use the memcache cache driver.",
"ext-pcntl": "Required to use all features of the queue worker and console signal trapping.",
"ext-pdo": "Required to use all database features.",
Expand Down
41 changes: 41 additions & 0 deletions config/image.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

return [

/*
|--------------------------------------------------------------------------
| Default Image Driver
|--------------------------------------------------------------------------
|
| This option controls the default image processing driver that will be
| used when manipulating or converting images. This driver is always
| utilized unless another driver is explicitly specified instead.
|
| Supported: "gd", "imagick", "cloudflare"
|
*/

'default' => env('IMAGE_DRIVER', 'gd'),

/*
|--------------------------------------------------------------------------
| Image Drivers
|--------------------------------------------------------------------------
|
| Here you may configure the Cloudflare Images API credentials for the
| "cloudflare" image driver. The prefix option is used to namespace
| temporary uploads so that orphaned images may be pruned safely.
|
*/

'drivers' => [

'cloudflare' => [
'account_id' => env('IMAGE_CLOUDFLARE_ACCOUNT_ID'),
'api_token' => env('IMAGE_CLOUDFLARE_API_TOKEN'),
'prefix' => env('IMAGE_CLOUDFLARE_PREFIX', 'laravel-image'),
],

],

];
13 changes: 13 additions & 0 deletions src/Illuminate/Contracts/Image/Driver.php
Comment thread
nunomaduro marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Illuminate\Contracts\Image;

use Illuminate\Foundation\Image\PendingImageOptions;

interface Driver
{
/**
* Process the given image contents with the specified options.
*/
public function process(string $contents, PendingImageOptions $options): string;
}
9 changes: 9 additions & 0 deletions src/Illuminate/Filesystem/FilesystemAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Contracts\Filesystem\Cloud as CloudFilesystemContract;
use Illuminate\Contracts\Filesystem\Filesystem as FilesystemContract;
use Illuminate\Foundation\Image\Image;
use Illuminate\Http\File;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
Expand Down Expand Up @@ -284,6 +285,14 @@ public function path($path)
return $this->prefixer->prefixPath($path);
}

/**
* Create an image instance from a file in storage.
*/
public function image(string $path): Image
{
return new Image(fn () => $this->get($path));
Comment thread
nunomaduro marked this conversation as resolved.
}

/**
* Get the contents of a file.
*
Expand Down
1 change: 1 addition & 0 deletions src/Illuminate/Foundation/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -1657,6 +1657,7 @@ public function registerCoreContainerAliases()
'filesystem.disk' => [\Illuminate\Contracts\Filesystem\Filesystem::class],
'filesystem.cloud' => [\Illuminate\Contracts\Filesystem\Cloud::class],
'hash' => [\Illuminate\Hashing\HashManager::class],
'image' => [\Illuminate\Foundation\Image\ImageManager::class],
'hash.driver' => [\Illuminate\Contracts\Hashing\Hasher::class],
'log' => [\Illuminate\Log\LogManager::class, \Psr\Log\LoggerInterface::class],
'mail.manager' => [\Illuminate\Mail\MailManager::class, \Illuminate\Contracts\Mail\Factory::class],
Expand Down
233 changes: 233 additions & 0 deletions src/Illuminate/Foundation/Image/Drivers/CloudflareDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
<?php

namespace Illuminate\Foundation\Image\Drivers;

use DateTimeImmutable;
use finfo;
use Illuminate\Contracts\Image\Driver;
use Illuminate\Foundation\Image\ImageException;
use Illuminate\Foundation\Image\PendingImageOptions;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\Pool;
use Illuminate\Support\Str;

class CloudflareDriver implements Driver
{
/**
* Create a new Cloudflare driver instance.
*/
public function __construct(
protected HttpFactory $http,
protected string $accountId,
protected string $apiToken,
protected string $prefix,
) {
//
}

/**
* Ensure the Cloudflare credentials are configured.
*
* @throws ImageException
*/
public function ensureRequirementsAreMet(): void
{
if (empty($this->accountId) || empty($this->apiToken)) {
throw new ImageException(
'The Cloudflare image driver requires an account ID and API token.',
);
}

if (empty($this->prefix)) {
throw new ImageException(
'The Cloudflare image driver requires a prefix for temporary uploads.',
);
}
}

/**
* Process the given image contents with the specified options.
*
* @throws ImageException
*/
public function process(string $contents, PendingImageOptions $options): string
{
$sourceMimeType = (new finfo(FILEINFO_MIME_TYPE))->buffer($contents);

if (! in_array($sourceMimeType, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'])) {
throw new ImageException("The image format [{$sourceMimeType}] is not supported by the Cloudflare driver.");
}

$id = $this->prefix.'/'.Str::random(40).match ($sourceMimeType) {
'image/jpeg' => '.jpg',
'image/png' => '.png',
'image/gif' => '.gif',
'image/webp' => '.webp',
};

$response = $this->http
->withToken($this->apiToken)
->attach('file', $contents, basename($id))
->post("https://api.cloudflare.com/client/v4/accounts/{$this->accountId}/images/v1", [
'id' => $id,
]);

if ($response->failed()) {
throw new ImageException(
'Cloudflare image upload failed: '.$response->json('errors.0.message', 'Unknown error'),
previous: $response->toException(),
);
}

return $this->transformAndDelete(
$response->json('result.id'),
$response->json('result.variants', []),
$options,
$contents,
$sourceMimeType,
);
}

/**
* Fetch the transformed image and delete the original from Cloudflare.
*
* @param array<int, string> $variants
*/
protected function transformAndDelete(string $imageId, array $variants, PendingImageOptions $options, string $contents, string $sourceMimeType): string
{
try {
if (empty($variants)) {
throw new ImageException('Cloudflare did not return any image variants.');
}

$acceptMimeType = $options->format !== null
? match ($options->format) {
'webp' => 'image/webp',
'jpg', 'jpeg' => 'image/jpeg',
}
: $sourceMimeType;

$request = $this->http->withHeaders([
'Accept' => $acceptMimeType,
]);

$response = $request->get(
$this->buildTransformUrl($variants[0], $options, $contents),
);

if ($response->failed()) {
throw new ImageException(
'Failed to fetch transformed image from Cloudflare: '.$response->json('errors.0.message', 'Unknown error'),
previous: $response->toException(),
);
}

return $response->body();
} finally {
rescue(fn () => $this->deleteImage($imageId));
}
}

/**
* Build the Cloudflare transform URL with the given options.
*/
protected function buildTransformUrl(string $baseUrl, PendingImageOptions $options, string $contents): string
{
$params = [];

if ($options->coverWidth !== null && $options->coverHeight !== null) {
$params[] = "width={$options->coverWidth}";
$params[] = "height={$options->coverHeight}";
$params[] = 'fit=cover';
} elseif ($options->scaleWidth !== null && $options->scaleHeight !== null) {
$params[] = "width={$options->scaleWidth}";
$params[] = "height={$options->scaleHeight}";
$params[] = 'fit=scale-down';
} else {
[$width, $height] = getimagesizefromstring($contents);

$params[] = "width={$width}";
$params[] = "height={$height}";
$params[] = 'fit=scale-down';
}

if ($options->blur !== null) {
$params[] = "blur={$options->blur}";
}

if ($options->greyscale) {
$params[] = 'saturation=0';
}

if ($options->sharpen !== null) {
$params[] = 'sharpen='.max(0, min(10, round($options->sharpen / 10)));
}

if ($options->flip && $options->flop) {
$params[] = 'flip=hv';
} elseif ($options->flip) {
$params[] = 'flip=v';
} elseif ($options->flop) {
$params[] = 'flip=h';
}

if ($options->format !== null) {
$params[] = 'format='.match ($options->format) {
'jpg' => 'jpeg',
default => $options->format,
};
}

$params[] = 'quality='.($options->quality ?? PendingImageOptions::DEFAULT_QUALITY);

return preg_replace('#/[^/]+$#', '/'.implode(',', $params), $baseUrl);
}

/**
* Delete orphaned images from Cloudflare that match the configured prefix.
*/
public function pruneOrphaned(): void
{
$page = 1;

do {
$response = $this->http
->withToken($this->apiToken)
->get("https://api.cloudflare.com/client/v4/accounts/{$this->accountId}/images/v1", [
'per_page' => 100,
'page' => $page,
]);

if ($response->failed()) {
throw new ImageException(
'Failed to list images from Cloudflare: '.$response->json('errors.0.message', 'Unknown error'),
previous: $response->toException(),
);
}

$images = $response->json('result.images', []);

collect($images)
->filter(fn (array $image) => str_starts_with($image['id'], $this->prefix.'/'))
->reject(fn (array $image) => (new DateTimeImmutable($image['uploaded']))->getTimestamp() > time() - 300)
->pluck('id')
->chunk(10)
->each(fn ($chunk) => $this->http->pool(fn (Pool $pool) => $chunk->map(
fn (string $id) => $pool->withToken($this->apiToken)
->delete("https://api.cloudflare.com/client/v4/accounts/{$this->accountId}/images/v1/{$id}"),
)->all()));

$page++;
} while (count($images) === 100);
}

/**
* Delete the temporary image from Cloudflare.
*/
protected function deleteImage(string $imageId): void
{
$this->http
->withToken($this->apiToken)
->delete("https://api.cloudflare.com/client/v4/accounts/{$this->accountId}/images/v1/{$imageId}");
}
}
16 changes: 16 additions & 0 deletions src/Illuminate/Foundation/Image/Drivers/GdDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Illuminate\Foundation\Image\Drivers;

use Intervention\Image\ImageManager;

class GdDriver extends InterventionDriver
{
/**
* Create the underlying Intervention image manager.
*/
protected function createManager(): ImageManager
{
return ImageManager::gd();
}
}
16 changes: 16 additions & 0 deletions src/Illuminate/Foundation/Image/Drivers/ImagickDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Illuminate\Foundation\Image\Drivers;

use Intervention\Image\ImageManager;

class ImagickDriver extends InterventionDriver
{
/**
* Create the underlying Intervention image manager.
*/
protected function createManager(): ImageManager
{
return ImageManager::imagick();
}
}
Loading
Loading