Pramnos Framework - Media System Guide¶
The Pramnos Framework includes a comprehensive media management system that handles file uploads, image processing, thumbnail generation, and media organization. The system provides automatic resizing, cropping, rotation, and supports various media types including images, documents, and PDFs.
Table of Contents¶
- Overview
- Basic Usage
- Media Types
- File Upload
- Image Processing
- Thumbnails
- Media Organization
- Advanced Features
- Database Schema
- API Reference
Overview¶
The Media system consists of several key components:
- MediaObject (
\Pramnos\Media\MediaObject) - Main media management class - ResizeTools (
\Pramnos\Media\ResizeTools) - Image resizing and processing - Thumbnail (
\Pramnos\Media\Thumbnail) - Thumbnail representation - File Management - Organized storage with automatic directory creation
Key Features¶
- Multi-format Support: Images (JPG, PNG, GIF, BMP, ICO), PDFs, Office documents
- Automatic Processing: Thumbnail generation, image resizing, orientation fixing
- Organized Storage: Hierarchical directory structure by date and module
- Usage Tracking: Track media usage across different modules
- Deduplication: Automatic detection and linking of duplicate files
- Image Manipulation: Rotate, crop, resize with quality preservation
- Responsive Images: Multiple thumbnail sizes for different use cases
Basic Usage¶
Creating a MediaObject¶
use Pramnos\Media\MediaObject;
// Create new media object
$media = new MediaObject();
// Set basic properties
$media->mediatype = 1; // 1 = image
$media->name = 'Sample Image';
$media->description = 'A sample image for demonstration';
$media->module = 'gallery';
Uploading Files¶
// Upload from $_FILES
$media = new MediaObject();
$result = $media->uploadFile($_FILES['file'], 'gallery');
if ($media->error === false) {
echo "File uploaded successfully! Media ID: " . $media->mediaid;
} else {
echo "Upload failed: " . $media->error;
}
Adding Existing Images¶
// Add an existing image file
$media = new MediaObject();
$media->addImage('/path/to/existing/image.jpg', 'gallery', true); // true = delete original
// Add remote image
$media = new MediaObject();
$media->addRemoteImage('https://example.com/image.jpg', 'gallery');
if ($media->error !== false) {
// The URL was refused or the fetch failed. $media->error says which.
}
addRemoteImage() is the one that leaves the machine¶
These two sit one line apart and are not the same operation. addImage() reads a file this
application already has. addRemoteImage() makes a request from the server, to an address that —
for every caller shape this method reads as being for, an importer, a «fetch the logo from their
website» button, a picture-by-URL field — came from outside.
That matters because the server can reach places whoever supplied the address cannot: a cloud
provider's metadata endpoint on 169.254.169.254, an unauthenticated panel on loopback, the database
on a private address, the rest of the subnet. So three guards run here:
- the address is checked and then dialled through
Pramnos\Security\OutboundUrl: the host is resolved, every address inside this network is refused, onlyhttpandhttpsare accepted, credentials in the URL are refused, and the connection is made to the address that was approved rather than resolving the name a second time. Up to$media->remoteMaxRedirectsredirects (3 by default) are followed, and each hop is checked the same way before it is dialled — a redirect is a second address, chosen by the server being fetched. Set it to0where an address is expected to be exact. - the body is capped at
$media->remoteMaxBytes(10 MB by default), mid-stream. - the type is read from the bytes, not from the URL — JPEG, PNG, GIF, WebP, BMP or ICO — and the
extension on disk follows the content. That matters because everything under
www/uploads/is served back by the web server according to that extension.
SVG is deliberately not on the accepted list for a remote fetch: it is markup, it can carry script,
and served from your own origin that script is same-origin. An application that wants remote SVGs owns
that decision and can fetch and sanitise one itself, then hand the file to addImage().
On refusal the object's error is set and nothing is written. It does not throw, because a bad URL in
an import of two thousand should not abort the import.
The request itself is one overridable method, fetchRemote(). It exists so the decisions after the
bytes arrive can be tested — the type read from the content, the extension that follows, the refusal of
an error status — because reaching them otherwise means a real outbound request. Overriding it in
application code means opting out of the host check, the byte cap and the redirect checking, so do not.
Media Types¶
The system supports different media types with specific handling:
// Media type constants
$media->mediatype = 0; // Generic file
$media->mediatype = 1; // Image
$media->mediatype = 2; // Emoticon/small image
$media->mediatype = 3; // PDF document
$media->mediatype = 4; // Flash media (deprecated)
$media->mediatype = 5; // Video
Type-Specific Processing¶
// Images get automatic thumbnails and resizing
$imageMedia = new MediaObject();
$imageMedia->mediatype = 1;
$imageMedia->max = 1024; // Max width/height
$imageMedia->medium = 600; // Medium size
$imageMedia->thumb = 120; // Thumbnail size
// PDFs get placeholder thumbnails
$pdfMedia = new MediaObject();
$pdfMedia->mediatype = 3;
File Upload¶
Basic File Upload¶
// Handle form upload
if (isset($_FILES['upload'])) {
$media = new MediaObject();
// Set upload constraints
$media->max = 2048; // Max size: 2048px
$media->medium = 800; // Medium size: 800px
$media->thumb = 150; // Thumbnail: 150px
$media->deleteOriginal = false; // Keep original
$media->uploadFile($_FILES['upload'], 'user_uploads');
if ($media->error === false) {
$media->save();
echo "Upload successful: " . $media->url;
}
}
Multiple File Upload¶
// Regroup one multi-file input into one array per file
\Pramnos\General\Helpers::fixFilesArray($_FILES['uploads']);
foreach ($_FILES['uploads'] as $file) {
$media = new MediaObject();
$media->uploadFile($file, 'gallery');
if ($media->error === false) {
$media->save();
}
}
Upload Validation¶
$media = new MediaObject();
// Set allowed file types
$media->mediatype = 1; // Images only
// Upload will automatically validate:
// - File type (by extension and MIME type)
// - File size (by server limits)
// - Image dimensions
// - Security (filename sanitization)
$result = $media->uploadFile($_FILES['file'], 'gallery');
Image Processing¶
Automatic Processing¶
$media = new MediaObject();
// Set processing options
$media->max = 1920; // Maximum dimension
$media->maxHeight = 1080; // Maximum height
$media->medium = 800; // Medium size
$media->mediumHeight = 600; // Medium height
$media->thumb = 200; // Thumbnail size
$media->thumbHeight = 150; // Thumbnail height
$media->fixOrientation = true; // Fix EXIF orientation
$media->uploadFile($_FILES['image'], 'gallery');
0 means «skip this rendition»¶
For all six of max, maxHeight, medium, mediumHeight, thumb and thumbHeight. An axis set to
0 is not consulted; both axes of a rendition at 0 means that rendition is not derived at all.
So medium = 0 means «no medium rendition», never «a rendition at some default width».
«Store this, derive nothing»¶
$media = new MediaObject();
$media->deriveNothing = true; // I have my own resizer; be the library
$media->addImage($file, 'logos');
For an application that wants what this class is good at — the storage layout, the md5 dedupe, the
mime, mediause — and wants its own resizer to own the pixels. The thumbnails column still holds
original, medium and thumb, all three pointing at the untouched file: callers ask for those by
name, and the entries describe what is actually there.
max is storage; maxRequest is retrieval¶
$media->max = 0; // do not rewrite what I store
$media->maxRequest = 2048; // but a get() may ask for up to 2048px
Two different questions, so two numbers: max bounds what is stored, maxRequest bounds the size a
get() may ask for. Keeping them apart is what lets an application set max = 0 to protect its
originals and still get sized renditions.
maxRequest at 0 (the default) falls back to max, and then to ResizeTools' own ceiling, so
nothing changes for a store that never sets either.
Renditions are never larger than the source¶
$media->get(512, 512); // a 40x40 source answers 40x40
$media->allowUpscale = true; // …unless you actually want invented pixels
Requests are clamped to the source's dimensions, because stretching would write blur to disk, record it as a rendition and serve it as real — larger than the original it came from. The clamp scales by the limiting factor so the requested aspect survives: a 40×40 source asked for 512×256 gives 40×20, not 40×40.
A Thumbnail's x/y are also read back from the file that was written rather than left as what was
asked for. They diverge exactly when something went wrong, which is when a recorded size is worth
having.
A vector answers with itself, and an unreadable file with the original¶
$svg = new MediaObject();
$svg->addImage('/path/to/logo.svg', 'logos');
$svg->isVector(); // true
$svg->get(128, 64); // the SVG itself, at its own dimensions
An SVG is already every size, so the original is the rendition. GD cannot decode SVG on an ordinary build, so resizing one is not attempted.
The same holds for any file GD cannot read — a truncated upload, a file whose extension lies.
get() returns the original, sets $media->error, and records nothing: a row pointing at a file
that was never created is worse than no row, because the next get() at that size finds it, fails the
file_exists() check, deletes it and saves. One write per request, for ever.
mediatype stays 1 for an SVG. It is an image, and every application's own mediatype == 1 branch
means «this is a picture» — giving vectors a new number would quietly route them into whatever those
branches do with a type they do not know.
Manual Image Processing¶
// Load existing media
$media = new MediaObject();
$media->load(123);
// Ask for a rendition with the object's current settings
$media->allowUpscale = false;
$rendition = $media->get(800, 600, false, true); // force: rebuild even if one exists
Image Rotation¶
$media = new MediaObject();
$media->load(123);
// Rotate image (by 90 degrees; arbitrary angles are not public API)
$media->rotateLeft();
$media->rotateRight();
Transparency¶
A PNG keeps its alpha through resizing and through cropping. Both paths prepare their
canvases with imagealphablending(false) + imagesavealpha(true), which is what stops GD
compositing a transparent pixel onto the opaque black a truecolor canvas starts as.
Two things to know when a thumbnail comes back with black where it should be see-through:
exporttypedecides. The alpha handling is applied when the output is PNG. A JPEG has no alpha to keep, so transparency flattens onto black there — which is GD's behaviour, not a bug, and the fix is to export PNG (or WebP) for images that need it.- The cropping path has an intermediate canvas. The source is scaled onto it before it reaches the thumbnail, and that canvas is prepared for alpha as well — otherwise a cropped PNG would arrive with its transparent regions already black.
Thumbnails¶
Automatic Thumbnail Generation¶
// Thumbnails are created automatically during upload
$media = new MediaObject();
$media->uploadFile($_FILES['image'], 'gallery');
// Access thumbnails
foreach ($media->thumbnails as $thumbnail) {
echo "Size: " . $thumbnail->x . "x" . $thumbnail->y . "\n";
echo "URL: " . $thumbnail->url . "\n";
echo "Reason: " . $thumbnail->reason . "\n"; // 'original', 'medium', 'thumb'
}
Getting Specific Thumbnails¶
$media = new MediaObject();
$media->load(123);
// Get predefined sizes
$thumb = $media->getThumb(); // Standard thumbnail
$medium = $media->getMedium(); // Medium size
$original = $media->url; // The original file's URL
// Get custom size (creates if doesn't exist)
$custom = $media->get(300, 200, true); // 300x200, cropped
What was asked for, and what came out¶
get($width, $height) is a request for a box, not a promise of those exact
dimensions. With allowUpscale off — the default — a source smaller than the box
comes back at the source's own size: get(177, 222) against a 150×150 image
answers 120×150, because inventing pixels and recording them as a rendition is
worse than a smaller image.
So a stored rendition carries both numbers:
| Property | Meaning |
|---|---|
x, y |
what the rendition actually is — the size of the file on disk |
requestedX, requestedY |
the box that produced it |
$rendition = $media->get(177, 222); // 150×150 source
$rendition->x; // 120 — read this to lay out a page
$rendition->y; // 150
$rendition->requestedX; // 177 — this is what the cache is keyed on
$rendition->requestedY; // 222
Why both, rather than just the result. The lookup has to find the entry again
from the same request, and the request is the only thing the caller has. Keyed on
the result, get(177, 222) never matched the 120×150 entry it had just written,
so every call rebuilt the image, appended another entry and saved the row — and
because the resizer prefixes a random number when the filename it wants already
exists, that was an unbounded number of files on disk and entries in the column,
for one image at one size.
requestedX/requestedY are 0 on entries written before they existed, and on
entries belonging to an application's own thumbnail class, which the reader hands
back unconverted. Those are matched on x/y as they always were, so nothing
stored has to be migrated — the thumbnails column is JSON of the declared
fields, and a field absent from a row reads back as the class default.
If you need the rendition to fill the box, ask for it on the call:
$filled = $media->get(177, 222, false, false, false, true, true);
// ^ allowUpscale
$filled->x; // 177 — stretched to the frame
The seventh argument overrides $media->allowUpscale for that call and leaves the
object alone. That distinction is the point: the property also governs the
derivatives generated at upload, so a template that wants its frame filled should not
turn it on for the images being produced to store. Pass null — the default — and the object's setting
applies, so nothing that does not pass it changes.
Which to use:
| Caller | Upscale |
|---|---|
| a template with a fixed frame | yes — a gap is worse than a soft image |
| producing a derivative to store | no — a rendition larger than its source is not a thumbnail |
What identifies a rendition¶
Dimensions do not. The same box asked for with different settings produces
different images at identical dimensions, so three of get()'s arguments are part
of an entry's identity and a lookup will not hand one to a caller that asked for
another. Measured, 155×148 from an 800×400 source:
crop |
resample |
result | image |
|---|---|---|---|
true |
true |
155×148 | 6116aa5c |
true |
false |
155×148 | 6116aa5c — cropping ignores resample |
false |
true |
155×148 | d94b13a9 — the whole image, letterboxed |
false |
false |
155×148 | 6f320215 — the whole image, stretched |
Three different pictures, one size. So Thumbnail records crop, resample and
upscaled beside requestedX/requestedY, and all five have to agree before an
entry is reused. The direction that matters is the one a lookup would otherwise
get wrong: serving a cropped image to a caller that asked for the whole picture,
or stretched blur to one that asked for no upscaling.
resample is recorded even though it changes nothing when crop is true. An
entry is identified by what was asked for; a lookup cannot know that this
particular source made the answer moot.
Entries written before these fields existed get the class defaults, which are
get()'s own — so they are treated as having come from the commonest call. That
is an assumption, not a fact: nothing recorded these at the time, so an entry
produced with crop = true is indistinguishable from one that was not. Guessing
wrong costs one rebuild and one extra row, once, and then the entry carries its
own settings. The alternative is to trust dimensions alone, which is the defect
these fields close.
Custom Thumbnail Creation¶
$media = new MediaObject();
$media->load(123);
// Create custom size thumbnail
$thumbnail = $media->get(
400, // Width
300, // Height
true, // Crop to exact size
false, // Don't force recreation
false, // No debug
true // Use resampling for quality
);
echo "Custom thumbnail URL: " . $thumbnail->url;
Media Organization¶
Module-Based Organization¶
// Files are organized by module
$media = new MediaObject();
$media->uploadFile($_FILES['file'], 'gallery'); // Goes to /uploads/gallery/
$media->uploadFile($_FILES['file'], 'products'); // Goes to /uploads/products/
$media->uploadFile($_FILES['file'], 'blog'); // Goes to /uploads/blog/
Date-Based Structure¶
The system automatically creates a hierarchical directory structure:
www/uploads/
├── gallery/
│ ├── 2024/
│ │ ├── 01/
│ │ │ ├── 15/
│ │ │ │ ├── image1.jpg
│ │ │ │ └── thumb_image1.jpg
│ │ │ └── 16/
│ │ └── 02/
│ └── 2023/
└── products/
└── 2024/
Usage Tracking¶
// Track where media is used
$media = new MediaObject();
$media->load(123);
// Add usage
$media->addUsage(
'blog', // Module
'post-456', // Specific item ID
'Featured Image', // Title
'Main blog post image', // Description
'featured,blog', // Tags
1 // Order
);
// Get all usages
$usages = $media->getUsages('blog');
foreach ($usages as $usage) {
echo "Used in: " . $usage->usageModule . " - " . $usage->usageSpecific;
}
Advanced Features¶
Deduplication¶
// The system automatically detects duplicates by MD5 hash
$media = new MediaObject();
$media->uploadFile($_FILES['file'], 'gallery');
// If file already exists, $media->medialink will point to original
if ($media->medialink > 0) {
echo "This file already exists as Media ID: " . $media->medialink;
}
Batch Operations¶
// Update multiple media usages
MediaObject::multipleUsageUpdate(
[123, 456, 789], // Media IDs
'gallery', // Module
'album-1' // Specific ID
);
// Clear all usages for a module
$media = new MediaObject();
$media->clearUsage('old_module', 'item-123');
Media Lists¶
// Get media by type
$media = new MediaObject();
$imageList = $media->getList(1, 'gallery'); // Type 1 (images) from gallery module
// Get media by user
$userMedia = $media->getList(0, '', 123); // All types, any module, user ID 123
Database Schema¶
Created by the shipped migration — Pramnos\Framework\Migrations\Core\CreateMediaTables,
which builds both tables in one up() because mediause.mediaid has a cascading foreign key onto
media.mediaid and the parent has to exist first. Run migrate and they are there.
That migration is the definition; read it rather than any copy of the schema.
media¶
One row per stored file. Twenty-three columns; the ones worth knowing:
| Column | Notes |
|---|---|
mediaid |
Signed auto-increment. Not UNSIGNED — mediause.mediaid is signed, and MySQL refuses a foreign key between columns that differ in signedness. |
md5 |
Content hash. Indexed, because every upload looks it up before storing: where md5 = %s and medialink = 0. Empty when the file could not be read — see below. |
mimetype |
What the file actually is, read with finfo at upload. '' when it could not be read. |
medialink |
When this row is a duplicate, the mediaid holding the real file. 0 means "not a duplicate" — a sentinel, which is why there is no foreign key here. |
userid |
The uploader, or 0 for no signed-in user — the same reason there is no key on it. |
order |
Display order, for emoticon sets. A reserved word in both backends; see below. |
thumbnails |
JSON of each thumbnail's fields. Older rows holding PHP-serialised objects are still read; see below. |
otherusers, othermodules |
tinyint flags: may other users / other modules see this file. |
mediause¶
One row per usage, so one file can appear in many places — users.photo holds a usageid.
| Column | Notes |
|---|---|
usageid |
Signed auto-increment. |
mediaid |
Cascading foreign key onto media.mediaid, both directions. Deleting a file removes its usages; a usage naming no file is refused. |
module, specific |
Which record uses it. Indexed as a pair, because three separate queries filter on module, on specific, or on both. |
order |
Display order within the record that uses it — a gallery is ordered. |
mimetype, and why it is not mediatype¶
uploadFile() reads the real type with finfo to decide whether the content matches the extension
— the check that refuses a PHP script named holiday.jpg — and stores it. That keeps the security
decision auditable, and lets anything serving the file later use the real type instead of re-guessing
it from the extension, which is precisely the claim the check exists to distrust.
mediatype is not a substitute. It is a display family — 1 image, 2 emoticon, 3 PDF, 0 other — and
cannot tell a png from a jpeg. It is what the class branches on ten times over; mimetype is what
the file is.
Both entry points fill it: uploadFile() from the check it already performs, and addImage() from
a detection of its own. addImage() gains no validation — it takes a file the application already
has, not one a visitor sent.
An unreadable file gets an empty hash, not the hash of nothing¶
file_get_contents() on a missing file returns false, and md5(false) is md5('') —
d41d8cd98f00b204e9800998ecf8427e, the same value for every missing file. Since a re-upload is
found with where md5 = %s and medialink = 0, every file whose bytes had gone was a duplicate of
every other one, and the next upload could be linked to any of them. So createMd5() leaves the hash
empty instead, which matches nothing — the honest answer for a file nobody can read.
thumbnails holds JSON, not serialised objects¶
PHP's serialize() writes the class name into the data. That single fact is behind four separate
problems, and JSON of the fields avoids all four:
- who can read a row depended on which classes the reading process had. This framework on its
own, a second application on the same database, a CLI script that skips the first application's
autoloader — each got
__PHP_Incomplete_Classand a fatal on the first property read. - renaming a property silently dropped every stored value, because property names are in the payload too. No error, just an empty field from then on.
unserialize()instantiates classes and runs their magic methods. Any write access to that column was a step towards code execution. (The framework already knew:Helpers::checkUnserialize()exists and passesallowed_classes => false.)- nothing outside PHP could read it. No SQL, no
JSON_EXTRACT, no reporting tool, and no index on «thumbnails wider than 500px».
Thumbnail is a set of scalar properties with no methods and no nesting, so there is nothing about it
JSON cannot carry.
No migration is needed. The reader accepts both formats — a leading [ distinguishes JSON from
serialize()'s a: — and the writer only produces JSON, so a row converts itself the next time its
media object is saved. Objects from a legacy row come back as they are rather than being recast
into Thumbnail: an application with its own thumbnail class gets its own type from those rows, and
changing that on read would alter what getThumb() hands its callers for rows nobody has touched.
An unencodable payload — a filename in some encoding json_encode() refuses — falls back to
serialize() for that row and logs it. Losing the thumbnails would be worse, and the reader accepts
both anyway.
A legacy thumbnail the reading process cannot load is dropped, not fatal¶
A legacy serialised row carries its class name — so who can read it depends on which classes that process has. An application with its own thumbnail class reads its own rows fine.
A different reader — this framework on its own, a second application sharing the database, a CLI
process that never boots the first application's class aliases — gets __PHP_Incomplete_Class from
unserialize(), and reading any property of one is a fatal error. So such entries are dropped on
load, and getThumb() falls back to an empty Thumbnail, as it does for a file with no thumbnails. unserialize() is deliberately
not restricted with allowed_classes: that would be better hardening and would also discard an
application's own thumbnail class that loads perfectly well today.
order and specific are reserved words¶
Both are real column names here and both are reserved in MySQL and PostgreSQL. MediaObject's own
queries are hand-written SQL with the quoting already in place, and the query builder's grammar quotes
them — and the rest of the words that turn up as column names — so where('order', 5) is valid on both
backends.
If you are naming a column, this still argues for avoiding a reserved word. These two are kept because they are what is running.
An installation that predates this migration¶
hasTable() guards both createTable() calls, so migrate leaves existing tables alone — it will
not touch a media that has been there for years. Such a table may differ from what the migration
now creates: character set, the index on md5, whether (module, specific) is indexed, and the width
of filesize and date. Reconciling that is the application's decision, not the framework's, and
nothing in the framework depends on it.
API Reference¶
MediaObject Class Methods¶
Upload Methods¶
uploadFile($file, $module, $type)- Upload file from $_FILESuploadImage($file, $module)- Upload image fileaddImage($filepath, $module, $deleteOriginal)- Add existing imageaddRemoteImage($url, $module)- Download and add remote image
Processing Methods¶
rotateLeft()- Rotate 90 degrees leftrotateRight()- Rotate 90 degrees right
Thumbnail Methods¶
get($width, $height, $crop = false, $force = false, $debug = false, $resample = true, $allowUpscale = null)- Get/create a renditiongetThumb()- Get standard thumbnailgetMedium()- Get medium size imageisVector()- Whether the file is an SVGpublicUrl($reason)/static urlFor($mediaid, $reason)- Address of the original or a rendition
Database Methods¶
load($mediaid)- Load media by IDsave($force)- Save media to databasedelete()- Delete media and files
Usage Methods¶
addUsage($module, $specific, $title, $description, $tags, $order)- Add usagegetUsages($module, $specific, $removeDuplicates)- Get usage listclearUsage($module, $specific, $safe)- Remove usagesremoveUsage($usageid, $safe)- Remove specific usage
Utility Methods¶
createMd5()- Generate MD5 hash of filegetList($type, $module, $userid)- Get media list
ResizeTools Class Methods¶
Main Methods¶
resize($src, $width, $height)- Resize image to dimensionsdisplay($src, $width, $height)- Output resized image directly
Configuration Properties¶
$maxsize- Maximum allowed dimension (default: 1024)$defaultwidth- Default width when not specified (default: 120)$crop- Allow cropping when both dimensions set (default: true)$resample- Use resampling for quality (default: true)$fillcolor- Background fill color for resampling (default: "FFFFFF")$debug- Enable debug output (default: false)
memory_limit while filling a thumbnail¶
Resampling with a $fillcolor other than black calls imagefill(), which on a large
image can want more memory than a constrained host allows. ResizeTools therefore raises
memory_limit to at least 256 MB for that one call and puts the old value back.
It only ever raises it. A host configured with 512 MB, or with no limit, is left alone: setting 256 MB there would be a reduction, and PHP refuses it outright once the process is already using more. Nothing to configure.
If 256 MB is not enough for the images an application handles, raise the host's own
memory_limit — anything at or above the floor is respected as-is.
Thumbnail Class Properties¶
$filename- Full file path$url- Web-accessible URL$x- Width in pixels$y- Height in pixels$filesize- File size in bytes$views- View counter$reason- Creation reason ('original', 'medium', 'thumb', 'custom')$createdTxt- Creation time$requestedX,$requestedY- The box that was asked for$crop,$resample,$upscaled- The settings that produced it
Loading a picture by its id¶
The constructor loads when given an id, and new MediaObject() with nothing is unchanged.
0, '' and null all mean "no id" rather than row zero, because callers reach this with
an unvalidated route segment.
empty($media->mediaid) after construction is therefore the "not found" test.
Drawing a transparent image — blend() and silhouette()¶
use Pramnos\Media\ResizeTools;
// A logo at 60%, without turning its clear pixels grey
ResizeTools::blend($card, $logo, 20, 20, 120, 120, 0.6);
// A glow behind it: the logo's own shape in white, not a rectangle
$glow = ResizeTools::silhouette($logo, 0xFFFFFF, 40);
ResizeTools::blend($card, $glow, 18, 18, 124, 124);
The reason these exist is that the obvious call is the wrong one. GD's compositing functions divide in two:
| Respects the source's alpha | Does not |
|---|---|
imagecopy(), imagecopyresampled() |
imagecopymerge(), imagecopymergegray() |
…and the second group is the one whose signature takes a percentage. So "draw this at
60%" leads straight to imagecopymerge(), which composites every transparent pixel as
though it were opaque and gives you a grey rectangle with the logo faintly on it.
The same shape produces a halo drawn as a filled rectangle behind a transparent PNG, and scratch canvases that start opaque black, so anything not overwriting every pixel gains a background. A logo is the single most likely image in a CMS to have a transparent background, and this is what a CMS uses for logos.
What each one does¶
blend() copies the source onto a cleared scratch canvas, fades it with
IMG_FILTER_COLORIZE — whose fourth argument adds transparency, so a clear pixel stays
clear and an opaque one becomes as translucent as asked — and composites with
imagecopy(). The source is left untouched, so drawing the same logo twice does not give
you the second one at the product of both opacities.
silhouette() returns the image's own shape in one colour. It walks the pixels rather than
using a filter, because IMG_FILTER_COLORIZE adds to the colour instead of replacing it
— a red logo colorised white comes out pink. The requested alpha is a floor added to
each pixel's own, so an anti-aliased edge stays soft rather than the glow gaining a hard
one.
Testing something you have drawn¶
Assert on a corner of the drawn box — inside the box, outside the artwork. That is where all three of those failures show, and it is the pixel a rectangle fills and a silhouette does not. A test that checks the middle passes on every one of them.
Putting media on object storage¶
Name a storage disk media and uploads are published to it as well as written locally:
// app/app.php
'storage' => [
'disks' => [
'media' => [
'driver' => 's3',
'bucket' => envvar('APP_S3_BUCKET'),
'region' => envvar('APP_S3_REGION'),
'key' => envvar('APP_S3_KEY'),
'secret' => envvar('APP_S3_SECRET'),
'url' => 'https://cdn.example.com',
],
],
],
Then ask for the address rather than building it:
With no media disk, none of this does anything — the file stays in www/uploads/,
publicUrl() answers the site's own address, and nothing is copied. Every existing
installation is that case and is unaffected.
The s3 driver needs aws/aws-sdk-php, which is the application's dependency rather than
the framework's: composer require aws/aws-sdk-php.
What is published, and what stays¶
| Published | the original and every rendition, keyed by url — the path relative to www/ |
| Kept locally | all of them. The local copy is the origin a later resize reads, and on a single server it is also what is served |
| On delete | delete() removes them from the disk as well |
| Backfill | republishToStorage() puts what is missing and skips what is there |
Why the work stays local. GD writes with imagejpeg($image, $path) and needs a real
filesystem path, which a bucket does not have. So the resize pipeline is untouched and only
its output is published — rather than a stream wrapper, which would hide the difference
until it surfaced as a warning from inside GD.
Publishing is best effort: a disk that is down does not fail an upload that has already been
accepted and saved. The row is the record and the disk is a copy of it, which is also why
publishToStorage() runs after save().
Best Practices¶
1. File Upload Security¶
// Always validate uploads
$media = new MediaObject();
$media->mediatype = 1; // Restrict to images only
// Set reasonable size limits
$media->max = 2048;
$media->medium = 800;
// Check for errors after upload
if ($media->uploadFile($_FILES['file'], 'gallery') && $media->error === false) {
$media->save();
}
2. Performance Optimization¶
// Use appropriate thumbnail sizes
$media = new MediaObject();
$media->thumb = 150; // For listing pages
$media->medium = 600; // For detail views
$media->max = 1920; // For full-size display
// Lazy load thumbnails
$thumbnail = $media->get(200, 200, false, false); // Don't force recreation
3. Storage Management¶
// Regular cleanup of unused media
$media = new MediaObject();
$unusedMedia = $media->getList(0, '', ''); // Get all media
foreach ($unusedMedia as $item) {
if ($item->usages == 0) {
// Consider for deletion after grace period
if ($item->date < (time() - (30 * 24 * 3600))) { // 30 days old
$item->delete();
}
}
}
4. Error Handling¶
try {
$media = new MediaObject();
$media->uploadFile($_FILES['file'], 'gallery');
if ($media->error !== false) {
throw new Exception("Upload failed: " . $media->error);
}
$media->save();
} catch (Exception $e) {
\Pramnos\Logs\Logger::log("Media upload error: " . $e->getMessage());
// Handle error appropriately
}
Related Documentation¶
- Framework Guide - Core framework concepts
- Database Guide - Database operations
- Theme Guide - Media display in themes
- Framework Guide - Application structure
- Logging Guide - Error logging and debugging
Troubleshooting¶
Common Issues¶
- Upload Failures
- Check file permissions on upload directory
- Verify PHP upload limits (upload_max_filesize, post_max_size)
-
Ensure sufficient disk space
-
Thumbnail Generation Issues
- Verify GD extension is installed
- Check memory limits for large images
-
Ensure write permissions on thumbnail directories
-
File Not Found Errors
- Verify file paths are correct
- Check that files weren't manually deleted
-
Use the path fixing functionality for migrated sites
-
Performance Issues
- Optimize image sizes before upload
- Use appropriate thumbnail sizes
- Consider CDN for large media libraries
For additional debugging, enable debug mode on ResizeTools and check the application logs for detailed error information.