Skip to content

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

  1. Overview
  2. Basic Usage
  3. Media Types
  4. File Upload
  5. Image Processing
  6. Thumbnails
  7. Media Organization
  8. Advanced Features
  9. Database Schema
  10. 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, only http and https are 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->remoteMaxRedirects redirects (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 to 0 where 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:

  • exporttype decides. 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_Class and 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 passes allowed_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 $_FILES
  • uploadImage($file, $module) - Upload image file
  • addImage($filepath, $module, $deleteOriginal) - Add existing image
  • addRemoteImage($url, $module) - Download and add remote image

Processing Methods

  • rotateLeft() - Rotate 90 degrees left
  • rotateRight() - Rotate 90 degrees right

Thumbnail Methods

  • get($width, $height, $crop = false, $force = false, $debug = false, $resample = true, $allowUpscale = null) - Get/create a rendition
  • getThumb() - Get standard thumbnail
  • getMedium() - Get medium size image
  • isVector() - Whether the file is an SVG
  • publicUrl($reason) / static urlFor($mediaid, $reason) - Address of the original or a rendition

Database Methods

  • load($mediaid) - Load media by ID
  • save($force) - Save media to database
  • delete() - Delete media and files

Usage Methods

  • addUsage($module, $specific, $title, $description, $tags, $order) - Add usage
  • getUsages($module, $specific, $removeDuplicates) - Get usage list
  • clearUsage($module, $specific, $safe) - Remove usages
  • removeUsage($usageid, $safe) - Remove specific usage

Utility Methods

  • createMd5() - Generate MD5 hash of file
  • getList($type, $module, $userid) - Get media list

ResizeTools Class Methods

Main Methods

  • resize($src, $width, $height) - Resize image to dimensions
  • display($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

$media = new \Pramnos\Media\MediaObject($id);

if (empty($media->mediaid)) {
    // No such picture.
}

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:

<img src="<?php echo htmlspecialchars($media->publicUrl('thumb')); ?>">

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
}

Troubleshooting

Common Issues

  1. Upload Failures
  2. Check file permissions on upload directory
  3. Verify PHP upload limits (upload_max_filesize, post_max_size)
  4. Ensure sufficient disk space

  5. Thumbnail Generation Issues

  6. Verify GD extension is installed
  7. Check memory limits for large images
  8. Ensure write permissions on thumbnail directories

  9. File Not Found Errors

  10. Verify file paths are correct
  11. Check that files weren't manually deleted
  12. Use the path fixing functionality for migrated sites

  13. Performance Issues

  14. Optimize image sizes before upload
  15. Use appropriate thumbnail sizes
  16. Consider CDN for large media libraries

For additional debugging, enable debug mode on ResizeTools and check the application logs for detailed error information.