preflow / media
Preflow media — the asset library: uploads, responsive image variants, private files
Requires
- php: >=8.4
- nyholm/psr7: ^1.8
- preflow/core: ^0.54 || @dev
- preflow/data: ^0.76 || @dev
- preflow/routing: ^0.59 || @dev
- preflow/view: ^0.67 || @dev
Requires (Dev)
- phpunit/phpunit: ^11.0
- preflow/devtools: ^0.34 || @dev
- preflow/i18n: ^0.68 || @dev
Suggests
- preflow/devtools: The media:* commands
- preflow/i18n: Per-language alt text for assets, and alt texts as a machine-translation source
Provides
None
Conflicts
- preflow/core: <0.54
- preflow/data: <0.76
- preflow/i18n: <0.47
- preflow/routing: <0.59
- preflow/view: <0.67
Replaces
None
- dev-main
- v0.76.0
- v0.75.0
- v0.74.0
- v0.73.0
- v0.72.1
- v0.72.0
- v0.71.1
- v0.71.0
- v0.70.0
- v0.69.2
- v0.69.1
- v0.69.0
- v0.68.1
- v0.68.0
- v0.67.0
- v0.66.2
- v0.66.1
- v0.66.0
- v0.65.0
- v0.64.0
- v0.63.2
- v0.63.1
- v0.63.0
- v0.62.0
- v0.61.0
- v0.60.1
- v0.60.0
- v0.59.0
- v0.58.1
- v0.58.0
- v0.57.0
- v0.56.1
- v0.56.0
- v0.55.0
- v0.54.1
- v0.54.0
- v0.53.2
- v0.53.1
- v0.53.0
- v0.52.0
- v0.51.0
- v0.50.0
- v0.49.1
- v0.49.0
- v0.48.5
- v0.48.4
- v0.48.3
- v0.48.2
- v0.48.1
- v0.48.0
- v0.47.1
- v0.47.0
- v0.46.1
- v0.46.0
- v0.45.0
- v0.44.0
- v0.43.0
- v0.42.1
- v0.42.0
- v0.41.2
- v0.41.1
- v0.41.0
- v0.40.0
- v0.39.0
- v0.38.0
- v0.37.0
- v0.36.0
- v0.35.0
- v0.34.1
- v0.34.0
This package is auto-updated.
Last update: 2026-11-14 19:29:44 UTC
README
The asset library for Preflow: uploads, responsive image variants (WebP/AVIF), replacing a file with versions, copies of an asset, a bin for deleted files, cleanup of unused ones, extra fields, colour-managed variants, PDF and video previews, image colours, private files, and where each asset is used. The package does all of it by itself. Folio is only a UI adapter on top (library screen, asset field); it adds screens and binds its own rules to the hooks below.
Requires preflow/core, preflow/data, preflow/view, preflow/routing and nyholm/psr7;
suggests preflow/i18n (alt text per language).
Install
composer require preflow/media
Without Folio, list the provider in config/providers.php:
Preflow\Media\MediaServiceProvider::class,
With Folio you add nothing: Folio registers it. Listing it as well is harmless, because a provider class is registered once.
The provider reads the config at boot, registers the MediaLibrary, adds the routes for
missing variants and private files, and the template functions media_image(),
media_url(), media_preview_url(), media_colors() and media_color_class(). With SQL storage, preflow migrate creates the asset table (the
media.table step), so a site without Folio needs no manual SQL.
Library
Files are written to public/media/Y/m/{id}.{ext} and served by the web server directly.
Metadata (alt text per language, title, focal point, video poster) are records stored in the
folio_assets table (the name is kept, so existing data keeps working).
$library = $container->get(Preflow\Media\MediaLibrary::class); $asset = $library->upload($uploadedFile, $userId); // Asset, throws AssetUploadException $library->find($id); $library->all(); $library->update($id, ['alt' => [...]]); $library->delete($id); // refuses a used asset: AssetInUseException lists the usage
delete()refuses an asset that is used anywhere (see usage sources) and throwsPreflow\Media\Usage\AssetInUseException. Withbin_dirset it moves the asset into the bin instead of removing it (see The bin).find()andall()return live assets only;findBinned()andbinned()reach the bin.importFile($path, $name, $by, private: true)copies a file from disk into the library as a private asset, likeupload(..., private: true); the source stays where it is.find($id),all()andquery()(below) read assets;replace(),versions(),restore(),deleteVersion()andprune()are in Replace and versions.- Lookups are memoised per request.
find()keeps the assets it found, so showing one twice costs one query (unknown ids are not kept). Callprefetch($ids)before rendering a list whose ids you collected: oneWHERE ... INloads them all instead of one query per image. Writes through the library empty the memo; callflush()after writing assets with theDataManagerdirectly, and between jobs in a long-running process. - Uploads do their work at once.
upload()(and a replace or restore) makes the variants, reads the colours and, for a PDF, renders its preview before it returns, in the same request. An image takes as long as its encodes; a PDF can add up to 30 seconds (the render's time limit). There is no queue. discard($id)is intended only for an asset created in this request that nothing references yet, e.g. when the record that should reference it fails validation. It bypasses the usage check, so do not use it for anything else.
Config
config/media.php:
return [ 'dir' => __DIR__ . '/../public/media', // default 'url' => '/media', 'storage' => 'json', // a data driver name 'max_bytes' => 20 * 1024 * 1024, 'max_pixels' => 30_000_000, // larger images get no variants 'heic_max_pixels' => 100_000_000, // larger HEIC/HEIF photos are refused before decoding 'warm_on_upload' => false, // true: make the step widths during the upload too 'allow_svg' => false, 'widths' => [480, 768, 1024, 1600], // default: the config/image.php preset widths 'quality' => 75, 'private_dir' => __DIR__ . '/../storage/private-media', // default; never under public/ 'private_url' => '/files', // default 'versions_dir' => __DIR__ . '/../storage/media-versions', // default; never under public/ 'bin_dir' => null, // where deleted assets wait (see The bin); null: no bin 'cleanup' => ['enabled' => false, 'min_age_days' => 7, 'bin_days' => 30], // see Cleanup 'fields' => [], // extra asset fields, see Extra fields 'filters' => [], // names of extra fields offered as filters 'color_profile_cmyk' => null, // ICC file for CMYK images without a profile; null: the shipped FOGRA39L 'ghostscript' => null, // gs binary for PDF previews; null: looked up on PATH 'pdftocairo' => null, // Poppler's pdftocairo; null: looked up on PATH 'ffmpeg' => null, // ffmpeg binary for video previews; null: looked up on PATH 'ffprobe' => null, // ffprobe binary (video length); null: looked up on PATH ];
- Without
config/media.php, an existingconfig/folio.phpassetsblock is read instead (deprecated, logged to the debug collector and listed in the devtools debug toolbar). The two are never merged: a presentmedia.phpwins entirely. - Who may download private files is not a media setting; see Private files.
- A
private_dir,versions_dirorbin_dirinsidepublic/or the media directory is refused at boot, and so is acolor_profile_cmykthat is not a readable file. versions_dirandprivate_dirset tonull(or'') turn the feature off: without aversions_dir,replace()throwsAssetUploadExceptionrather than lose the current file.
Images in templates
{{ media_image(record.cover, 'hero', {sizes: '(min-width: 60rem) 50vw, 100vw', class: 'cover'}) }}
<a href="{{ media_url(record.whitepaper) }}">Download</a>
media_image()emits<picture>with AVIF and WebPsrcsets and an<img>withwidth,height,loadingand the alt text in the page's language. A document renders as a link.media_url()gives an asset's URL (''without one; the master for an edited image).media_original_url()gives the untouched original. Both accept what an asset field stores: a library id, a legacy upload path, or a list (its first item).- Options and presets are those of
responsive_image()(config/image.phppresets:widths,sizes,loading,class,attrs), plus:step: 160adds widths every 160 px between the preset's smallest and largest width (made on first request, see below);priority: truefor the page's key image:fetchpriority="high",loading="eager"and one<link rel="preload" as="image">per image in the head. A layout that prints{{ head() }}before the body has already written its head, so leave the head to the automatic injection to keep the link.
- The focal point becomes classes (
media-focal media-fx-30 media-fy-70), never an inline style; their CSS is added with the CSP nonce. It shows only when your CSS crops the image (object-fit: cover).
Variants
Made on upload with GD or Imagick: every configured width the image can fill, plus its own
width when narrower than the largest. Each as WebP, and AVIF where supported. Photos are
turned upright by their EXIF orientation. Images above max_pixels keep only the original.
GIF and SVG are served as they are; without GD or Imagick no variants are made.
After changing widths, run:
php preflow media:variants [--id=<id>] [--all] [--limit=<n> [--offset=<n>]]
(--all also makes the step widths.) --limit makes the variants of at most n images (newest
first) and prints the command that continues, with --offset; see Long runs below. The command is listed in the package's
extra.preflow.commands; it needs preflow/devtools.
Step widths on first request. Listed widths are made on upload; step widths when a
browser first asks for one: the missing file reaches the front controller, the core makes it,
writes it next to the original and returns it, and from then on the web server serves it.
Only widths a preset defines, never wider than the original and in a supported format, are
made; anything else under the media URL is a 404 that writes nothing. Concurrent first
requests wait on a per-file lock. warm_on_upload or media:variants --all make them in
advance (use one on shared hosting). If an encode fails, the browser is redirected
(uncached) to the closest existing size. A page under the media URL is not routed.
Internal renders. A request marked as internal (the SEO audit rendering a page from inside the app,
Preflow\Core\Http\InternalRender) never makes a variant. An allowed size that doesn't exist yet answers
404 with the header X-Preflow-Variant: pending, so a checker can tell "not made yet" from "missing" (a
disallowed or unknown one is a plain 404).
Box presets: height and fit
A preset without height is a width preset, as before. With height it gets a fixed box;
fit says how the image fills it:
// config/image.php 'presets' => [ 'card' => ['widths' => [400, 800], 'sizes' => '50vw'], // width preset (fit: max) 'thumb' => ['width' => 400, 'height' => 300, 'fit' => 'cover'], // cut to 4:3 'strip' => ['width' => 1200, 'height' => 300, 'fit' => 'cover', 'widths' => [600, 1200]], 'logo' => ['width' => 300, 'height' => 200, 'fit' => 'contain'], // whole image inside the box ],
fit: max(the default) is today's behaviour: the image keeps its shape.fit: covercuts the box's ratio out of the image, centred on the focal point (or on the format crop, see below).fit: containkeeps the whole image (or the image crop) and only sets the box'swidth/heightattributes.- A
coverorcontainpreset needs a positivewidthandheight. Itswidthsdefault to 1x and 2x ofwidth. A box file is never wider than its source region (no upscaling). - Names. A box preset's name matches
^[a-z][a-z0-9-]*$. It is notedit,previewororiented, does not start withedit-,preview-ororiented-, and is notv<digits>orv<digits>-…; these would collide with the names of other derived files. Any other name is a boot error that namesconfig/image.phpand the preset. Presets withfit: maxkeep any name. - File names.
{stem}-{preset}-{key}-{w}.{fmt}, for example2026/10/{id}-v3-thumb-1a2b3c4d-400.webp; for an edited image the stem is the master's:2026/10/{id}-edit-9f8e7d6c-thumb-1a2b3c4d-400.webp. The key (8 hex characters) changes with the box, the crop region and the focal point, so an edit gives new URLs and old ones are not cached stale. - Made on first request, not at upload: the missing file reaches the front controller,
which cuts and writes it (like step widths; anything that is not one of the asset's current
box files is a 404 that writes nothing).
warm_on_uploadandmedia:variants --allmake them in advance. Any edit or focal-point change deletes the asset's box files. - Box files are cut from the delivered image (the master of an edited image) and are sRGB and stripped. Private assets get no box variants.
Editing images
An image can be rotated, mirrored and cropped without touching the uploaded file. The edit
is data on the asset; the library renders a master from the original and pages deliver
the master. Editing needs a public JPEG, PNG, WebP or AVIF image; private images, GIF and SVG
are refused (InvalidArgumentException).
use Preflow\Media\CropRect; $library->isEditable($asset); // public JPEG, PNG, WebP or AVIF $library->rotate($id, 1); // quarter turns clockwise (negative: counter-clockwise) $library->flip($id, true); // mirror horizontally (false: vertically) $library->setCrop($id, CropRect::of(10, 5, 80, 60)); // x, y, width, height in percent; null removes it $library->setFormatCrop($id, 'thumb', CropRect::of(0, 0, 50, 50)); // cut for one cover preset; null removes it
Each call returns the saved asset and renders the master straight away.
- Order. EXIF upright, then mirror, then rotate clockwise, then the image crop. The result
is the master
{stem}-edit-{key}.{jpg|png|webp}(AVIF originals get a WebP master), with its variants{stem}-edit-{key}-{w}.{fmt}. The original file stays as uploaded. - Coordinates. A
CropRectis in percent of the rotated and flipped original and is clamped to the image. The focal point is in percent of the delivered image (the master when edited), soobject-positionkeeps its meaning. Rotating or mirroring moves the crop, the format crops and the focal point along; setting the crop moves the focal point with it. A crop of the whole image is stored as no crop. - Format crops (
setFormatCrop) art-direct onecoverpreset. They are cut from the master, so a format crop is clamped to the image crop: to show an area outside the image crop, remove the image crop first. Rotating clears the format crops (rotating by whole turns,rotate($id, 0)or4, changes nothing); mirroring mirrors them.formatRegion($asset, $preset)returns the region a preset cuts, in the same coordinates. - What reads what.
media_url()andurl()give the master for an edited image; documents keep their file URL.media_original_url()($library->originalUrl()) gives the untouched original (a legacy upload path gives its legacy URL).deliveryAsset()is the asset as pages see it. - Cropping does not hide the original. The untouched original stays public at its own URL
(
media_original_url()), cropped-away areas included. Use a private asset for content that must not be public. - Colour. The master is sRGB and stripped. A CMYK or wide-gamut edited image needs
Imagick: without it no master is made, the asset's
notesaysneeds Imagick for colour-accurate variantsand pages keep the original. - Reserved field names. New asset fields
rotation(0 to 3),flip,crop,cropsandmastercannot be used as extra field names. Existing assets read as unedited. - Replace and restore keep the edits (the crop applies as a percentage to the new file); restoring a version brings back the edits it had. Restoring a version made before 0.36 keeps the asset's present edits (such a version has none stored).
- Custom backends.
ImageBackendhas a new method,writeEdited()(renders the master: upright, mirror, turns, crop, longest side, JPEG/PNG/WebP), andwriteVariants()jobs may carry acrop. A third-party backend must implementwriteEdited().
Replace and versions
replace() swaps an asset's file and keeps everything that makes the asset itself: the id, so
every record that references it still does, plus alt text, title, focal point, extra fields,
usage, the private flag and the poster.
$asset = $library->replace($id, $uploadedFile, $userId); // or a path on disk (importers) $library->versions($id); // list<AssetVersion>, newest first $library->restore($id, 2, $userId); // make version 2 the current file again $library->deleteVersion($id, 2); $library->prune($id, keep: 3, olderThanDays: 90); // returns how many were deleted
- The new file must be the same kind (image, video or document,
Asset::kind()), elseAssetUploadExceptionand nothing changes. A mime change inside the kind is fine. - Changes with the file: size, width, height, mime, original name, preview, colours and variants. Old public files and their variants are removed after a successful replace.
- The old file is first copied to
versions_dir({versions_dir}/{id}/v{n}.{ext}); the asset records it inversions(number, size, size in pixels, mime, original name, date, the focal point at that time). Version numbers only grow; a deleted number is not reused. - Names. Version 1 keeps today's names (
Y/m/{id}.{ext}, variants{id}-{w}.{fmt}). From version 2 the file isY/m/{id}-v{n}.{ext}and its variants{id}-v{n}-{w}.{fmt}, so a browser or CDN never serves the old bytes under the new name. Existing assets read as version 1 with no earlier versions; nothing is rewritten. restore()is a replace from an earlier file, so the current file becomes a version too. The restored version's focal point comes back; alt texts and title stay as they are. Restoring keeps the restored version.prune($id, $keep, $olderThanDays)keeps the$keepnewest earlier files and, with$olderThanDays, deletes only those replaced longer ago than that. The current file is never touched. Nothing is pruned automatically.- Replaces, restores, version deletes, prunes, edits (
update()), re-made variants, previews and colours, and the delete of one asset run one after another, each on the record the one before saved (a lock per asset in{versions_dir}/.locks/{id}.lock; the delete removes it). Amedia:variantsrun that read the asset before a replace never undoes it. - Serving. Earlier files are never under
public/. They are sent by the core route{private_url}/{id}/v/{n}behindPrivateFileAccess(see Private files), whether the asset is public or private. Without a bound rule that is a 403. versions_dirmust be a shared directory in a deployment (Deployershared_dirs:storage/media-versions) and git-ignored, or the next release deletes the earlier files.
php preflow media:versions:prune [--keep=N] [--older-than=DAYS] [--id=<id>]
Give --keep, --older-than or both; --id limits it to one asset.
Copies and makers
A copy is an asset of its own that remembers which asset it came from, so a library can show an
original with its copies (a cropped version, a watermarked one) and make a copy again when the
original changes. The copy has its own id, file, alt text, focal point and edits; the link is
derived_from (the original's id), plus maker, maker_params and derived_version.
// the copy's file comes from you (a watermark you drew, a cut-out) $copy = $library->derive($originalId, $path, $userId, maker: 'watermark', params: ['line' => '(c) Me']); // a copy of the file as it is now, with the original's crop, turn and mirror as its start $copy = $library->makeCopy($originalId, $userId); // an asset already in the library becomes a copy (files made before copies existed) $copy = $library->linkCopy($assetId, $originalId, maker: 'watermark', params: ['line' => '(c) Me']); $library->copies($originalId); // list<Asset>, newest first $library->copyCounts($ids); // [id => number of live copies]
derive($originalId, $file, $by, $maker, $params, $private)stores$fileas a copy of the original. Given the id of a copy it links to that copy's original, so stacks are one level deep.$privatenull takes the original's flag. The file is read from disk and stays where it is. The copy starts with the original's alt text (every language) and title. The makercopyis refused (InvalidArgumentException): it ismakeCopy()'s.makeCopy($id, $by)copies the file and the edits (crop, turn, mirror, format crops, focal point) so the copy starts as the original looks now; editing it changes only the copy. Called on a copy it makes a sibling with the same original, maker and settings. The copy starts with the copied asset's alt text (every language) and title. The maker is the reserved namecopy.linkCopy($id, $originalId, $maker = null, $params = [])marks an asset already in the library as a copy of$originalId: for files made before copies existed, such as watermarked images a site kept next to their originals. Only the record changes (derived_from,maker,maker_params, andderived_versionset to the original's current version); the file, alt text, title and edits stay.$makernull for a copy no maker can make again;copyis refused. It refuses (InvalidArgumentException, nothing changed) an asset linked to itself, an original that is itself a copy (link to its original instead), an asset that has copies of its own (in the bin too), and an asset or original that is unknown or in the bin.- Makers. A maker produces a copy's file from its original, so it can be produced again:
use Preflow\Media\Asset; use Preflow\Media\Derived\{DerivedMaker, DerivedMakers}; final class Watermark implements DerivedMaker { public function name(): string { return 'watermark'; } public function make(Asset $original, string $file, array $params): string { $out = tempnam(sys_get_temp_dir(), 'wm'); // draw $params['line'] onto $file, write the result to $out return $out; // the library owns this temp file and deletes it } } // in your provider's boot() $container->get(DerivedMakers::class)->register(new Watermark());
- What a maker receives: the
Assetof the original,$fileand the copy's$params.$fileis the original as people see it: turned and mirrored, without the crop, at full size (the raw file for an unedited image, a video or a document). It is a temp file the library deletes afterwards. The maker returns the path of a new temp file, which the library owns as well. Only the makercopyis different: it takes the original's raw file and keeps the copy's own crop, turn and mirror. DerivedMakersis one registry per container (#[Singleton]); register inboot().copyis reserved and cannot be registered (InvalidArgumentException).- Remake.
remake($copyId, $by, ?array $params = null)runs the maker again on the original's current file and swaps the result into the same copy, keeping its id, so everything that uses it keeps working; the old file becomes a version (needsversions_dir, likereplace()).$paramsnull keeps the stored settings. It refuses (InvalidArgumentException, nothing changed) an original, a copy whose maker is not registered, and a copy whose original is gone or in the bin. canRemake($copy)says whetherremake()can work;isStale($copy)is true when the original was replaced after the copy was made or remade (derived_versionis older than the original'sversion).- Copies are hidden in lists.
query()leaves them out unless you passcopies: trueor search, so a library shows originals; copies are reached throughcopies(). - Copies protect their original.
CopyReferences, a usage source the core registers, reports every live copy as a use of its original: the original cannot be deleted while a copy exists, cleanup keeps it, and emptying the bin skips it. A copy in the bin does not count.
Extra fields
Add fields of your own to every asset (a licence, a photographer, a category) in
config/media.php:
'fields' => [ 'credit' => ['type' => 'string', 'label' => 'Credit'], 'category' => ['type' => 'select', 'label' => 'Category', 'options' => ['team' => 'Team', 'event' => 'Event']], 'approved' => ['type' => 'boolean', 'label' => 'Approved'], 'author' => ['type' => 'relation', 'relation' => ['to' => 'author'], 'label' => 'Author'], ], 'filters' => ['category', 'approved', 'author'], // select, relation and boolean fields only
- A field has the shape of a model field (
type,label,options, …). Each becomes a text column of the asset type; values are inAsset::$extra($asset->extra['credit']). - A name must be letters, digits and underscores, starting with a letter, and must not be one
of the asset's own keys (
path,alt,title,color,palette,version,private, …): that is a boot error naming the field. $library->update($id, ['credit' => '…'])sets them. A value that does not fit its field (an option that does not exist) is cleared; other unknown keys are ignored.filtersentries that are not a declared select, relation or boolean field are ignored and reported to the debug collector.
Querying
$page = $library->query(kind: 'image', search: 'harbour', filters: ['category' => 'event'], page: 1, perPage: 50); $page->items; // list<Asset>, newest first; also ->total, ->page, ->perPage
kind is image, video or document (null: all). search matches every word in the
original name, title or alt text (in any locale). filters compare by equality on the
configured filters fields (a relation by id, a list means any of them; a multiple relation
means "contains"), and on the asset's own private flag. A page past the end gives the last
page. Works on JSON and SQL storage.
missingAlt: true keeps only images whose alt text in the default locale is null or empty
(what an SEO audit flags; only images are asked for one); it combines with the other
conditions (since 0.76). missingAltCount(kind, search, filters) counts that set with the same
conditions, so a "Missing alt text · N" toggle beside a search counts what it would list:
$library->query(search: 'harbour', missingAlt: true); // the harbour images without alt text $library->missingAltCount(search: 'harbour'); // how many of them $library->missingAltCount(); // every live original image without one
Colour management
Variants are always sRGB, so every browser shows the colours the photographer saw.
- A CMYK image is converted to sRGB. Without an embedded profile it is taken as FOGRA39L
(coated), a CC0 profile shipped in
packages/media/resources/icc/with its licence;color_profile_cmykpoints at another ICC file (SWOP, a newsprint profile, …). - An image with another profile, such as Adobe RGB or Display P3, is converted too.
- Conversion uses relative colorimetric intent with black-point compensation, and the output carries no embedded profile.
- sRGB and untagged images are not touched: they use the same backend (GD when it can write WebP) and produce the same files as before.
- Needs Imagick with lcms (the common build). Without Imagick a CMYK or non-sRGB-profiled
image gets no variants (the original is served) and the asset's
notereadsneeds Imagick for colour-accurate variants; the library shows it. Installing Imagick and runningmedia:variants --allfixes those assets.
PDF previews
A PDF gets a preview of its first page: a PNG in sRGB, long side 1600 px (capped by
max_pixels), stored next to the file (Y/m/{id}[-v{n}]-preview.png), or in private_dir
for a private asset. media_preview_url(id) returns its URL ('' without one); the library's thumbnails of
documents use it.
- Needs one of two programs on the server:
- Ghostscript 10.03.1 or newer (
gs), the colour-faithful engine: it follows a print PDF's CMYK output intent. Older versions have published-dSAFERescapes, so they are never run on uploaded files. - otherwise Poppler's
pdftocairo, which runs no PostScript. It converts CMYK naively and ignores the output intent, so print PDFs look less true. - Neither: no previews; uploads still work.
- Ghostscript 10.03.1 or newer (
- Both are looked up on PATH and in the usual places;
ghostscriptandpdftocairoinconfig/media.phpname the binaries. - Rendering uses an argument list (no shell), only the asset's own path, and a 30 second limit.
A broken, encrypted or empty PDF leaves
previewnull and the upload succeeds. - A private asset's preview is private too:
{private_url}/{id}/preview, behindPrivateFileAccess. - A replace makes the preview again.
- Rendering takes time on upload. It runs in the upload's request: a PDF can add up to 30 seconds before the upload returns.
media_image()on a PDF shows its preview (or its poster, if one is set) as a picture once a preview exists; before that, the asset stays a link. A private PDF's preview is the gated one.- A page larger than about 8000 pt (also by
/UserUnit) is fitted into the preview's size in one pass instead of being measured by drawing, so it costs no huge bitmap.
php preflow media:previews [--id=<id>] [--all] [--limit=<n>] [--offset=<n>]
Makes the previews of documents that have none; --all (or --id) makes them again, for
example after Ghostscript has been upgraded. A preview is rendered to a temp file and only
then swapped in, so a render that fails or is killed keeps the working preview. --id of an
unknown asset is an error (exit 1).
Video previews
A video (MP4, WebM, QuickTime) gets a preview from one frame: a PNG in sRGB, long side 1600 px,
stored next to the file (Y/m/{id}[-v{n}]-preview.png, in private_dir for a private asset). It
works like the PDF preview, so the library, the picker, media_image(), media_preview_url(), the
colours and the box formats treat a video like a document with a picture.
- Needs ffmpeg. It is looked up on PATH and in the usual places;
ffmpegandffprobeinconfig/media.phpname the binaries, so a static build placed in the hosting account works where the host has none.ffprobeis optional (it reads the length). Without ffmpeg there are no previews and videos keep their plain tile; uploads still work. - Which frame. For a video shorter than 10 seconds the frame at 10 % of its length, else the frame at 1 second. Without ffprobe, or when it cannot read the file, 1 second. If no frame comes out there, the first frame is tried. Non-square pixels are made square and a turned video is turned.
- A poster wins. An image poster set on the video (or on a document) is shown instead of the
preview, in the library and in
media_image(); clear the poster and the frame is back. thumbnailUrl($asset)is the small picture an admin list shows (the 480 variant first);displayUrl($asset, $minWidth)(since 0.76) is a large preview sharp at$minWidthpixels: the smallest WebP variant at least that wide, else the widest, else the original image, and for a video or document its poster or preview, the gated URL for a private file, null for other files.posterCounts($ids)(since 0.76) counts, in one query, how many assets use each id as their poster, live and binned;posterCounts($ids, binned: false)the live ones only.- ffmpeg is run with an argument list (no shell), reads only the asset's own file and has a
20 second limit for all its tries. The input format is pinned by the file's type (
-f movfor MP4 and QuickTime,-f matroskafor WebM), so ffmpeg never reads a crafted file as something else, such as a playlist. A broken file leavespreviewnull and the upload succeeds. - A replace makes the preview again. Like the PDF preview it is rendered in the upload's request.
php preflow media:previews [--id=<id>] [--all] [--limit=<n>] [--offset=<n>]
media:previews covers videos as well as PDFs: run it once after ffmpeg is installed to give
existing videos their preview.
Colours
Images (and the previews of documents and videos) get their colours when they are uploaded or replaced:
| Field | Meaning |
|---|---|
color |
the dominant colour, #rrggbb |
palette |
1 to 8 colours that occur in the image, most significant first (a flat image has fewer than 5) |
text_color |
#ffffff or #000000, whichever has the higher WCAG contrast on color |
SVGs have none, nor documents and videos without a preview. In templates:
{% set c = media_colors(record.cover) %} {# {color, rgb: 'r,g,b', palette, palette_rgb, text} or null #}
<section class="{{ media_color_class(record.cover) }}"> {# '' without colours #}
{{ media_image(record.cover, 'hero', {placeholder: true}) }}
</section>
media_color_class() returns a class media-c-{hash} whose rule sets CSS variables
--media-color, --media-color-rgb, --media-text and --media-palette-1 …
--media-palette-N. The rule goes into one <style> in the head with the CSP nonce, once per
page and colour set, so there is never an inline style. Use them in your CSS:
.card { background: rgba(var(--media-color-rgb), .8); color: var(--media-text); } .card a { border-color: var(--media-palette-2, var(--media-color)); }
{placeholder: true} for media_image() puts the dominant colour behind the <img> until
it has loaded.
php preflow media:colors --all # every asset without colours
php preflow media:colors --all --limit=200 # at most 200 of them, then says how many are left
php preflow media:colors --id=<id> # that asset again
Run --all once after upgrading, so existing assets get theirs.
Commands
All are in the package's extra.preflow.commands and need preflow/devtools.
| Command | Does |
|---|---|
media:variants [--id=<id>] [--all] [--limit=N] [--offset=N] |
makes the variants again (--all includes step widths and box variants) |
media:versions:prune [--keep=N] [--older-than=DAYS] [--id=<id>] |
deletes earlier files of replaced assets |
media:previews [--id=<id>] [--all] [--limit=N] [--offset=N] |
makes PDF and video previews |
media:bin [--restore=<id>] [--empty] [--limit=N] [--offset=N] |
lists the bin, restores an asset, empties what is old enough |
media:cleanup [--delete] [--kind=<kind>] [--by=<user>] [--private|--public] [--older-than=DAYS] [--limit=N] [--offset=N] |
lists unused assets, or moves them into the bin |
media:colors --all [--limit=N] [--offset=N] | --id=<id> |
reads the colours of images and previews |
Long runs. A host may end long SSH sessions (some after about 12 minutes). Run
the backfills in chunks with --limit=N: each run does at most N assets and, when some are
left, prints the command for the next chunk.
media:previewswithout--allandmedia:colors --allresume by themselves: an asset that got its preview or colours drops out of the list, so a stopped run is continued by running the same command again. When some could not be made, the printed command adds--offsetto skip them.media:previews --allandmedia:variantsredo every asset, so they continue with the printed--offset(the list is newest first; an upload in between shifts it by one, which only makes one asset twice).
php preflow media:colors --all --limit=300 # repeat until nothing is left
php preflow media:previews --limit=50 # repeat until nothing is left
php preflow media:variants --all --limit=200 # then: --all --limit=200 --offset=200, …
Usage sources
An asset counts as used where a registered source reports it. AssetUsage asks every
source in registration order; MediaLibrary::delete() refuses while any reports a use.
There is one AssetUsage per container (#[Singleton]), whichever provider asks for it
first.
- The core registers
PosterReferencesas the last source (register($source, last: true)), so poster uses list after record uses: an asset used as another asset's poster. - Folio registers its record source (working copies and published versions, nested blocks
included).
Preflow\Folio\Assets\AssetReferencesstays in Folio. - If you store asset references, register a source, or deleting an asset used there is not blocked:
use Preflow\Media\Usage\{AssetReferenceSource, AssetUsage, AssetUsageEntry, CountsAssetUsage}; final class OrderReferences implements AssetReferenceSource, CountsAssetUsage { public function name(): string { return 'orders'; } /** @return list<AssetUsageEntry> */ public function usagesOf(string $assetId): array { // one entry per place: label, edit URL, source, note, blocksDelete, type return [/* new AssetUsageEntry('Order 1042', '/admin/orders/1042', 'orders', type: 'order') */]; } /** Optional (CountsAssetUsage): a whole library page of ids in one query. */ public function usageCounts(array $assetIds, bool $nonBlocking = false): array { return [/* asset id => how many orders use it */]; } } // in your provider's boot() $container->get(AssetUsage::class)->register(new OrderReferences());
Register sources in your provider's boot(): every provider has registered by then, so the
order of config/providers.php does not matter. (A source registered in register() is
kept too, also before MediaServiceProvider.) To build the source only when an asset is
checked, wrap it: new LazyReferenceSource('orders', fn () => $container->get(OrderReferences::class)).
A source may scan its data on request or keep an index up to date on save; the interface does not care. It is asked on every delete, so keep it cheap: Folio's skips types without asset or blocks fields and, on SQL storage, reads only rows whose columns contain the id.
An AssetUsageEntry carries label, url (where it is edited, or null), source, an
optional note ("published version"), blocksDelete (false for a use that only keeps the
asset out of cleanup, such as an earlier revision) and, since 0.76, type: a key the source
defines for what uses it (a content type's key, asset for another asset), so a UI can show
that kind's icon and name; null when the source has none.
Counting a page of assets (since 0.76). AssetUsage::counts($ids) gives each id its
number of usages for a whole library page at once: a source that implements
CountsAssetUsage is asked once (usageCounts($ids, $nonBlocking)), any other usagesOf()
per asset. By default only the usages that block a delete count, as a library card's
"used N×" shows them, so a source can skip what holds only non-blocking ones (Folio does not
read its revisions then). counts($ids, nonBlocking: true) counts every usage, as of(),
isUsed(), cleanup and emptying the bin see them. A batch must answer what usagesOf()
would: the blocking entries, or all of them with $nonBlocking.
The bin
Set bin_dir and every delete moves the asset into the bin instead of removing it: a manual
delete, cleanup, and anything that calls delete(). Without bin_dir delete removes files for
good as before, and preflow migrate and Folio's library warn about it.
'bin_dir' => __DIR__ . '/../storage/media-bin', // never under public/ or the media dir
delete($id)still refuses a used asset. A binned asset is hidden fromfind(),all(),query(), the pickers andmedia_image();findBinned($id)andbinned($page, $perPage)(newest first) reach it.- What is kept. The asset's own files move to
{bin_dir}/{id}/: the current file, earlier versions and a document's or video's preview. Variants, the rendered master and box files are deleted and made again on restore. The record stays, withbinned_atset (UTC). restoreFromBin($id)moves the files back and makes the variants again. A copy whose original is in the bin restores the original first. It throws for an asset that is not in the bin, and (RuntimeException, nothing changed) when its file is not in{bin_dir}/{id}/files/, so a record never comes back without its file. So an asset that was binned while its file was already missing cannot be restored; it stays in the bin until it is emptied.- The bin stays on its server. Folio's export (
folio:export,folio:pull, backups) leaves binned assets out: their files are in this server's bin only. An import leaves the local bin alone, so binned assets can still be restored after a pull. - Posters and revisions are kept. A poster of a binned video or document, and an asset an
earlier revision still uses, are reported as non-blocking uses (
AssetUsageEntry::$blocksDeletefalse): you may move them to the bin by hand, but emptying the bin never deletes them for good while they could still be needed. - Emptying.
emptyBin($olderThanDays, ?Closure $progress = null, ?int $limit = null, int $offset = 0)deletes for good the assets binned longer ago than that, the longest binned first, and keeps any that a usage source still reports (with the reason).$progress($asset, $deleted)is called for each asset checked. In chunks,$limitchecks at most that many and$offsetskips that many old enough ones; the next chunk starts at$offsetplus the number kept. It returns aBinEmptyResult(deleted,kept,left). Nothing empties the bin by itself; run the command (a cron job works). bin_dirmust be a shared directory in a deployment (Deployershared_dirs:storage/media-bin) and git-ignored, or the next release deletes the bin.- Without a bin there is no restore:
discard()and a delete withoutbin_dirremove for good. So withoutbin_dir,delete()refuses under every usage, a non-blocking one (an earlier revision) included: a delete for good must never take a file a revision still shows.
php preflow media:bin # list the bin
php preflow media:bin --restore=<id> # bring one back
php preflow media:bin --empty # delete for good what is older than cleanup.bin_days
php preflow media:bin --empty --limit=100 [--offset=N] # in chunks; prints the command for the next one
The list takes --limit and --offset as well. --empty prints how many assets it deleted and
which it kept and why.
Cleanup
Cleanup finds assets that nothing uses and moves them into the bin. It is off until you turn it on:
'cleanup' => [ 'enabled' => false, // opt in: true exactly (a boolean); "1" or "yes" leaves it off 'min_age_days' => 7, // an asset is only looked at this long after its upload 'bin_days' => 30, // what media:bin --empty deletes: in the bin longer than this ],
Warning. Cleanup only knows the usages reported through usage sources. Folio reports its records and their revisions. An app that stores asset ids or URLs anywhere else must register an
AssetReferenceSource, or cleanup treats those assets as unused.
It covers every kind of asset, public and private (kinds image, video, document; text, ZIP
and Office files are documents). An asset counts as unused when no source reports it at all, a
non-blocking use (a poster, a revision, the original of a copy) included.
php preflow media:cleanup # list the unused assets and why; changes nothing
php preflow media:cleanup --delete # move them into the bin (needs bin_dir)
Options: --kind=image|video|document, --by=<uploader>, --private or --public,
--older-than=<days> (instead of min_age_days), --limit=<n> and --offset=<n>. With
--limit it checks at most n assets and prints the command that continues. While enabled is
false the command refuses and says why. Cleanup never deletes for good: a mistake is undone with
media:bin --restore=<id> until media:bin --empty has run. Folio adds an "Unused only" filter
to its library when cleanup is on.
Private files
Uploads with private: true go to private_dir, outside public/, get no variants, and
have one URL: {private_url}/{id} (/files/3f…e1). media_url() and media_image() (a
plain <img>, no srcset) use it.
- The core asks a
Preflow\Media\Access\PrivateFileAccessbefore it serves anything. With none bound, it denies with a 403 (DenyAllPrivateFileAccess). - Folio binds its rule: a signed-in user with a permission, from
config/folio.phpassets.private_permission(defaultfolio.dashboard) andassets.private_login(optional; a redirect tologin?next=…instead of a 403). These two stay infolio.php. - A site's own
PrivateFileAccessmust be bound by a provider listed afterFolioServiceProviderinconfig/providers.php, or Folio's binding replaces it. decide(ServerRequestInterface $request, Asset $asset): ?ResponseInterfacereturnsnullto allow, or the response to send (403, redirect). It is asked for every request on the route, also for an unknown id, so a refused visitor learns nothing about which ids exist.- Earlier versions (
{private_url}/{id}/v/{n}) and private previews ({private_url}/{id}/preview) go through the same rule. For a version the asset is passed as private and the request carries the attributePrivateFileAccess::VERSION(the version number; null on the other routes), so a rule can keep earlier files to fewer people. Folio's does: earlier versions are staff only (assets.view); its members' permission never opens them. - Files are sent with
Cache-Control: private, no-store; images inline, everything else as an attachment with the original name. - The route needs the session: the prefix of
private_urlmust be covered bysession.pathsinconfig/auth.phpand by the session cookie's path. private_dirandversions_dirmust be shared directories in a deployment and git-ignored.private_urlis registered as non-public (NonPublicPaths, seepreflow/core): an unknown file there never redirects and never shows up in Folio's not-found list.
Allowed files
The type is sniffed from the content, never from the client's name; the stored extension
follows it. Allowed: JPEG, PNG, GIF, WebP, AVIF, HEIC and HEIF (stored as JPEG), MP4, WebM,
QuickTime, PDF, plain text, CSV, ZIP, and Word, Excel and PowerPoint files. SVG is refused
unless allow_svg is set.
HEIC and HEIF
Phone photos (iPhones and many Android phones save HEIC) become JPEG assets on upload, import and replace.
- Needs Imagick with HEIC support (libheif). Check with
php -r 'print_r(Imagick::queryFormats("HEI*"));': it must listHEIC(HEIF files are read by the same delegate). Without it a HEIC or HEIF file is refused with{name}: HEIC/HEIF images need Imagick with HEIC support on the server, which is missing., and a file Imagick cannot decode with{name}: the HEIC/HEIF image could not be converted.Nothing is written in either case. HEIC sequences (bursts, live photos) stay refused. - Size limit: the header is read first, and a photo with more than
heic_max_pixels(default 100 million) is refused before it is decoded, with{name}: the HEIC/HEIF image is too large to convert.This is separate frommax_pixels, which only decides whether an image gets variants: a 48 MP phone photo is converted and kept even wheremax_pixelsis lower. Messages name the file as it was sent (IMG_1234.HEIC). - Of a file with several images (a thumbnail, a depth map) the primary image is converted.
- The JPEG (quality 92) is the asset's file: MIME
image/jpeg, extension.jpg, and the original name's extension becomes.jpg(IMG_1234.HEICis stored asIMG_1234.jpg), so a download matches its content.max_bytesstill checks the uploaded HEIC. - The HEIC original is not kept. Only Safari displays HEIC, and every library feature (variants, editing, boxes, colours, versions, the bin, private files, cleanup) works on the asset's one file. The JPEG keeps the colour profile and the EXIF data, so it loses nothing a page or an editor can see. A site that must archive camera originals keeps them outside the library.
- Orientation: libheif turns and mirrors the photo when it decodes it, so the pixels are upright and the JPEG says orientation 1 (also in its EXIF), and the photo is never turned twice. Width and height are the upright ones.
- Colour: the ICC profile stays, so a Display P3 photo gets sRGB variants through colour management like any JPEG.
- Another converter (a service,
heif-convert, …) implementsPreflow\Media\Image\HeicConverter(available(),toJpeg(string $bytes): string, which throws\RuntimeExceptionfor a file it cannot decode) and is passed to theMediaLibraryconstructor (heic:). The default isImagickHeicConverter, built withheic_max_pixels; a converter of your own throwsPreflow\Media\Image\HeicTooLargefor a file it refuses by size.
SQL storage
preflow migrate creates the table (media.table), as RecordTableSync makes any record
table: uuid as the primary key, private a yes/no column (INTEGER on SQLite, TINYINT(1) on
MySQL) and every other field a nullable text column (TEXT on SQLite, LONGTEXT on MySQL): path, original_name, mime, title, poster,
created_at, created_by, size, width, height, focal_x, focal_y, private,
duration, alt and variants (JSON), and version, versions (JSON), preview, color,
palette, text_color, note, the copy columns derived_from, maker, maker_params (JSON) and
derived_version (a number), binned_at and one column per extra field (typed by its field type). Columns
it adds later are typed the same way; existing columns are never altered. Run preflow migrate
when you upgrade: queries read derived_from and binned_at.
Use without Folio
List MediaServiceProvider, add config/media.php if the defaults do not fit, call
$library->upload() from your own controller, and render with media_image(). Bind a
PrivateFileAccess if you use private files, and register a usage source if you store
references. Folio is not needed.