Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased](https://github.com/Pollora/framework/compare/v13.36.1...develop)

### Added
- Media, image sizes: an `ImageSize` facade declares the sizes your design uses, `ImageSize::add('card')->cover(600, 400)->webp(75)`, with `cover()`, `scale()`, `contain()`, `webp()`, `avif()`, `original()`, `widths()`, `eager()` and `upscale()`. The file of a size is named `photo-card-600x400-{signature}.webp` (an HMAC of the original and of the size, made with the application key), so changing a size changes its name, and the URL, width, height and `srcset` of a size are worked out from the metadata of the original, never from the disk. The sizes WordPress registers itself, including a theme's `config/images.php`, are left as they are. `pollora:media:thumbs:warm` makes the missing files for the images already uploaded, `pollora:media:thumbs:prune` deletes the files of a size that was changed or removed, and `pollora:doctor` reports a declaration that cannot make a size or a format the server cannot write
- Media, private files: a media flagged private (the `_pollora_media_private` meta, or the `pollora/media/is_private` filter) is served through a signed URL that expires (`media.private.expiry`) on its URL, its sizes and its `srcset`; a public media keeps its plain URL and the disk is never asked to tell them apart. `pollora:media:visibility {id} --private|--public` changes every file of a media on the disk, `pollora:media:files {id}` lists them, and the module's actions reach Laravel as `MediaStored`, `MediaDeleted` and `MediaVisibilityChanged` events. The URL is signed by the Laravel disk (`temporaryUrl()`), and `pollora:doctor` warns of private media on a disk that cannot sign
- Media, images: the sizes a media does not need at once can be generated after the upload (`media.async_thumbnails`): `thumbnail` and `medium` come with the upload, the rest in an asynchronous action that runs on the queue, Action Scheduler or WP-Cron, and WordPress serves the nearest size meanwhile. An upload of an image with nine sizes no longer reads the original back, nor asks the disk for the size of each file, and the personal data export works on any disk (its archive is built locally, copied to the disk and deleted from it when it expires). Both WordPress image editors, GD and Imagick, work as they are
- The media library can live on any Laravel disk (local, S3, R2, Scaleway…) behind two settings, `media.disk` and `media.url` (`config/media.php`, `MEDIA_DISK`, `MEDIA_URL`). The storage is [`pollora/media`](https://github.com/Pollora/media); the framework keeps the connector: `MediaServiceProvider` (the Flysystem driver of the disk, the URL of the disk when `media.url` is empty), `pollora:media:status`, `pollora:media:migrate` (copy between disks, `--replace-urls` for the database, dry run unless `--execute`) and a check of `pollora:doctor` (the disk takes a file; no media left in the local uploads folder). WordPress's own file functions follow through a `pollora://` stream wrapper and the `upload_dir` filter, so Gutenberg, ACF, Meta Box, WooCommerce and imports need no adaptation; a media URL is `media.url` and a relative path, never asked to the disk. With no `media.disk`, nothing changes: WordPress keeps `public/content/uploads`
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"pollora/block-bindings": "^0.1",
"pollora/discovery": "^0.1 || ^1.0",
"pollora/entity": "^1.2",
"pollora/media": "^0.3",
"pollora/media": "^0.4",
"pollora/meta": "^0.1",
"pollora/hook": "^2.0.1",
"pollora/option": "^1.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Pollora\Media\Infrastructure\Laravel\Console;

use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Pollora\Media\Application\Services\ImageSizes;
use Pollora\Media\Application\Services\SizePruner;
use Pollora\Media\Domain\Contracts\MediaStorage;

/**
* Deletes the sized files that no declared size stands for any more. A dry run unless --execute.
*/
#[Description('Delete the image size files that no declared size stands for any more')]
#[Signature('pollora:media:thumbs:prune {--execute : Delete the files; without it, only list them}')]
class MediaThumbsPruneCommand extends Command
{
public function handle(MediaStorage $storage, ImageSizes $sizes): int
{
$execute = (bool) $this->option('execute');
$paths = (new SizePruner($storage, $sizes))->prune(! $execute);

foreach ($paths as $path) {
$this->line(($execute ? 'Deleted ' : 'Would delete ').$path);
}

$this->components->info(sprintf('%s %d file(s).', $execute ? 'Deleted' : 'Would delete', count($paths)));

if (! $execute) {
$this->components->info('This was a dry run. Add --execute to apply it.');
}

return self::SUCCESS;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Pollora\Media\Infrastructure\Laravel\Console;

use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use InvalidArgumentException;
use Pollora\Media\Domain\Contracts\MediaStorage;
use Pollora\Media\Infrastructure\WordPress\SizeWarmer;
use Pollora\Media\Infrastructure\WordPress\WordPressMedia;

/**
* Makes the missing files of the declared sizes for the images already in the library.
*/
#[Description('Make the missing files of the declared image sizes for the images already in the library')]
#[Signature('pollora:media:thumbs:warm {--size= : Only this declared size} {--since= : Only the images uploaded after this date}')]
class MediaThumbsWarmCommand extends Command
{
public function handle(WordPressMedia $media, MediaStorage $storage): int
{
try {
$report = (new SizeWarmer($media->declaredSizes(), $storage))->warm(
$this->option('size') ?: null,
$this->option('since') ?: null,
);
} catch (InvalidArgumentException $invalidArgumentException) {
$this->components->error($invalidArgumentException->getMessage());

return self::FAILURE;
}

$this->components->info(sprintf('%d image(s) looked at; %d file(s) made; %d failed.', $report['checked'], $report['made'], $report['failed']));

return $report['failed'] === 0 ? self::SUCCESS : self::FAILURE;
}
}
45 changes: 45 additions & 0 deletions src/Media/Infrastructure/Laravel/MediaCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
use Pollora\Doctor\Domain\Contracts\CheckInterface;
use Pollora\Doctor\Domain\Enums\RunContext;
use Pollora\Doctor\Domain\Models\CheckResult;
use Pollora\Media\Application\Services\ImageSizes;
use Pollora\Media\Application\Services\MediaStatus;
use Pollora\Media\Domain\Contracts\MediaStorage;
use Pollora\Media\Domain\Enums\OutputFormat;
use Pollora\Media\Domain\Models\MediaConfig;
use Throwable;

Expand All @@ -29,12 +31,14 @@
/**
* @param Closure(): MediaStorage $storage Resolved only when a disk is set
* @param Closure(): MediaConfig $config
* @param (Closure(): ImageSizes)|null $sizes The declared image sizes
*/
public function __construct(
private ?string $disk,
private Closure $storage,
private Closure $config,
private string $localUploads,
private ?Closure $sizes = null,
) {}

public function id(): string
Expand Down Expand Up @@ -80,6 +84,16 @@ public function run(RunContext $context): CheckResult
);
}

$sizeProblems = $this->sizeProblems();

if ($sizeProblems !== []) {
return CheckResult::warning(
sprintf('%d problem(s) with the declared image sizes.', count($sizeProblems)),
$sizeProblems,
'Fix the declaration (cover(), scale() or contain() with the dimensions it needs), or choose a format the image editor of this server can write (WebP: GD and Imagick; AVIF depends on the server)',
);
}

$left = $this->leftBehind($storage);

if ($left !== []) {
Expand All @@ -93,6 +107,37 @@ public function run(RunContext $context): CheckResult
return CheckResult::ok(sprintf('The disk "%s" takes a file; media are served from %s.', $this->disk, $config->url));
}

/**
* Declarations that could not make a size, and sizes in a format this server's image editor cannot write.
*
* @return list<string>
*/
private function sizeProblems(): array
{
if (! $this->sizes instanceof Closure) {
return [];
}

$sizes = ($this->sizes)();
$problems = $sizes->refusals();

if (function_exists('wp_image_editor_supports')) {
foreach ($sizes->all() as $spec) {
$mime = match ($spec->format) {
OutputFormat::Webp => 'image/webp',
OutputFormat::Avif => 'image/avif',
OutputFormat::Original => null,
};

if ($mime !== null && ! \wp_image_editor_supports(['mime_type' => $mime])) {
$problems[] = sprintf('The size "%s" is written in %s, which the image editor of this server cannot write.', $spec->name, $mime);
}
}
}

return $problems;
}

/**
* How many media are private, when the disk cannot sign a URL for them; null when it can, or when none is.
*/
Expand Down
12 changes: 11 additions & 1 deletion src/Media/Infrastructure/Laravel/MediaServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use League\Flysystem\FilesystemOperator;
use Pollora\Doctor\Infrastructure\Providers\DoctorServiceProvider;
use Pollora\Hook\Domain\Contract\Action;
use Pollora\Media\Application\Services\ImageSizes;
use Pollora\Media\Domain\Contracts\MediaStorage;
use Pollora\Media\Domain\Events\MediaDeleted;
use Pollora\Media\Domain\Events\MediaStored;
Expand All @@ -21,6 +22,8 @@
use Pollora\Media\Infrastructure\Laravel\Console\MediaFilesCommand;
use Pollora\Media\Infrastructure\Laravel\Console\MediaMigrateCommand;
use Pollora\Media\Infrastructure\Laravel\Console\MediaStatusCommand;
use Pollora\Media\Infrastructure\Laravel\Console\MediaThumbsPruneCommand;
use Pollora\Media\Infrastructure\Laravel\Console\MediaThumbsWarmCommand;
use Pollora\Media\Infrastructure\Laravel\Console\MediaVisibilityCommand;
use Pollora\Media\Infrastructure\WordPress\WordPressMedia;
use RuntimeException;
Expand Down Expand Up @@ -51,12 +54,19 @@ public function register(): void
fn (): MediaStorage => $app->make(MediaStorage::class),
fn (): MediaConfig => $app->make(MediaConfig::class),
rtrim((string) (defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR : public_path('content')), '/').'/uploads',
static fn (): ImageSizes => $app->make(ImageSizes::class),
));

// The file names of the sizes are signed with the application key.
$this->app->singleton(ImageSizes::class, fn (Application $app): ImageSizes => new ImageSizes(
static fn (): string => (string) $app->make('config')->get('app.key'),
));

$this->app->singleton(WordPressMedia::class, fn (Application $app): WordPressMedia => new WordPressMedia(
$app->make(MediaStorage::class),
$app->make(MediaConfig::class),
$app->make(Action::class),
$app->make(ImageSizes::class),
));
}

Expand All @@ -68,7 +78,7 @@ public function boot(): void
$this->app->tag([MediaCheck::class], DoctorServiceProvider::CHECKS_TAG);

if ($this->app->runningInConsole()) {
$this->commands([MediaStatusCommand::class, MediaMigrateCommand::class, MediaFilesCommand::class, MediaVisibilityCommand::class]);
$this->commands([MediaStatusCommand::class, MediaMigrateCommand::class, MediaFilesCommand::class, MediaVisibilityCommand::class, MediaThumbsWarmCommand::class, MediaThumbsPruneCommand::class]);
}

if ($this->diskName() === null) {
Expand Down
37 changes: 37 additions & 0 deletions src/Support/Facades/ImageSize.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Pollora\Support\Facades;

use Illuminate\Support\Facades\Facade;
use Pollora\Media\Application\Services\ImageSizeDeclaration;
use Pollora\Media\Application\Services\ImageSizes;

/**
* Facade for declaring the image sizes of the media library.
*
* A size has a name and a fit (`cover()`, `scale()`, `contain()`); its file is written in WebP by
* default, and its name carries a signature, so the URL of a size is worked out from the
* metadata of the original and never asks the disk. Sizes WordPress registers itself
* (`add_image_size()`, `config/images.php` of a theme) are left as they are.
*
* ```php
* ImageSize::add('card')->cover(600, 400)->webp(75);
* ImageSize::add('hero')->scale(width: 1920)->webp(70)->widths([640, 1024, 1440]);
* ```
*
* @method static ImageSizeDeclaration add(string $name) Declare a size
*
* @see ImageSizes
*/
class ImageSize extends Facade
{
/**
* Get the registered name of the component.
*/
protected static function getFacadeAccessor(): string
{
return ImageSizes::class;
}
}
53 changes: 51 additions & 2 deletions tests/Feature/Extraction/MediaContractTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
use Illuminate\Contracts\Filesystem\Factory;
use Illuminate\Filesystem\FilesystemManager;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Facade;
use Pollora\Doctor\Domain\Enums\RunContext;
use Pollora\Hook\Async\PendingAsync;
use Pollora\Hook\Domain\Contract\Action;
use Pollora\Hook\Domain\Contract\HookInterface;
use Pollora\Media\Application\Services\ImageSizes;
use Pollora\Media\Domain\Contracts\MediaStorage;
use Pollora\Media\Domain\Events\MediaDeleted;
use Pollora\Media\Domain\Events\MediaStored;
Expand All @@ -23,6 +25,7 @@
use Pollora\Media\Infrastructure\WordPress\AsyncThumbnails;
use Pollora\Media\Infrastructure\WordPress\StreamWrapper;
use Pollora\Media\Infrastructure\WordPress\WordPressMedia;
use Pollora\Support\Facades\ImageSize;

/**
* The media library on a Laravel disk, with the real provider, the real filesystem manager and a real local
Expand Down Expand Up @@ -154,8 +157,8 @@

it('hands the framework hook service to the module, so the thumbnails of an upload can be deferred', function (): void {
[$app, $provider] = ($this->makeApp)(['disk' => 'uploads', 'async_thumbnails' => true]);
Filters\expectAdded('intermediate_image_sizes_advanced')->once();
Filters\expectAdded('wp_generate_attachment_metadata')->twice(); // the thumbnails, and private media
Filters\expectAdded('intermediate_image_sizes_advanced')->twice(); // the thumbnails, and the declared sizes
Filters\expectAdded('wp_generate_attachment_metadata')->times(3); // the thumbnails, private media, the declared sizes
$this->action->shouldReceive('add')->with(AsyncThumbnails::HOOK, Mockery::type('array'))->once()->andReturn(Mockery::mock(HookInterface::class));
$this->action->shouldReceive('async')->once()->andReturn(new PendingAsync(static function (): void {}));

Expand Down Expand Up @@ -209,3 +212,49 @@
expect($result->status->name)->toBe('Warning')
->and($result->summary)->toContain('cannot sign URLs')->toContain('2 media');
});

it('declares image sizes through the facade, on the registry the module uses, signed with the application key', function (): void {
[$app] = ($this->makeApp)(['disk' => 'uploads']);
$app['config']->set('app.key', 'base64:test-application-key');
Facade::clearResolvedInstances();
Facade::setFacadeApplication($app);

try {
ImageSize::add('card')->cover(600, 400)->webp(75);
ImageSize::add('hero')->scale(width: 1920)->widths([640]);

$sizes = $app->make(ImageSizes::class);
$file = $sizes->names()->for('2026/10/photo.jpg', $sizes->get('card'), 3000, 2000);

expect(array_keys($sizes->all()))->toBe(['card', 'hero'])
->and($sizes)->toBe($app->make(ImageSizes::class))
->and($app->make(WordPressMedia::class)->imageSizes())->toBe($sizes)
->and($file)->toMatch('#^2026/10/photo-card-600x400-[0-9a-f]{12}\.webp$#');

$app['config']->set('app.key', 'base64:another-key');
expect($sizes->names()->for('2026/10/photo.jpg', $sizes->get('card'), 3000, 2000))->not->toBe($file);
} finally {
Facade::clearResolvedInstances();
Facade::setFacadeApplication(null);
}
});

it('has a doctor check that reports declarations it could not make into a size', function (): void {
[$app] = ($this->makeApp)(['disk' => 'uploads']);
$sizes = new ImageSizes(fn (): string => 'k');
$sizes->add('broken')->cover(600);
$check = new MediaCheck(
'uploads',
fn () => $app->make(MediaStorage::class),
fn () => $app->make(MediaConfig::class),
sys_get_temp_dir().'/pollora-media-no-local-'.bin2hex(random_bytes(3)),
fn (): ImageSizes => $sizes,
);
Monkey\Functions\when('get_posts')->justReturn([]);

$result = $check->run(RunContext::Console);

expect($result->status->name)->toBe('Warning')
->and($result->summary)->toContain('declared image sizes')
->and($result->details[0])->toContain('broken');
});
Loading