nvl / media
Laravel media uploads, associations, variations and localized metadata
Requires
- php: ^8.4
- ext-ctype: *
- ext-curl: *
- ext-dom: *
- ext-fileinfo: *
- ext-filter: *
- ext-libxml: *
- ext-mbstring: *
- guzzlehttp/guzzle: ^7.8.2 || ^8.0
- guzzlehttp/psr7: ^2.7
- laravel/framework: ^12.0 || ^13.0
- league/flysystem: ^3.25
- nesbot/carbon: ^2.72 || ^3.0
- nvl/core: ^6.0.1
- nvl/filterable: ^6.0
- nvl/translatable: ^6.0
- spatie/image: ^3.0
- spatie/laravel-data: ^4.23
- symfony/http-foundation: ^7.0 || ^8.0
- symfony/http-kernel: ^7.0 || ^8.0
Requires (Dev)
- ext-gd: *
- ext-pcntl: *
- aws/aws-sdk-php: ^3.322.9
- larastan/larastan: ^3.10
- laravel/pint: ^1.27
- league/flysystem-aws-s3-v3: ^3.0
- mockery/mockery: ^1.6
- nvl/ownership: ^6.0.1
- nvl/tenancy: ^6.0
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- shipmonk/composer-dependency-analyser: ^1.8
- spatie/typescript-transformer: ^3.3
- symfony/process: ^7.0 || ^8.0
Suggests
- aws/aws-sdk-php: Built-in S3 uploads and multipart storage: composer require league/flysystem-aws-s3-v3:^3.0 aws/aws-sdk-php:^3.322.9 --with-all-dependencies.
- league/flysystem-aws-s3-v3: Built-in S3 uploads and multipart storage: composer require league/flysystem-aws-s3-v3:^3.0 aws/aws-sdk-php:^3.322.9 --with-all-dependencies.
- nvl/ownership: Adds optional model-declared ownership, isolation, adoption and captured worker contexts.
- nvl/tenancy: Temporary legacy tenancy bridge for existing tenant installations; native ownership does not require it.
- spatie/typescript-transformer: Consume the declared TypeScript attributes and metadata (^3.3); Core retains the runtime transformation engine.
Provides
None
Conflicts
- aws/aws-sdk-php: <3.322.9 || >=4.0
- league/flysystem-aws-s3-v3: <3.0 || >=4.0
- nvl/ownership: <6.0.1 || >=7.0
- nvl/tenancy: <6.0 || >=7.0
Replaces
None
README
Quickstart
composer require nvl/media:^6.0 php artisan nvl:install media --dry-run php artisan nvl:install media
Required NVL dependencies: nvl/core (^6.0), nvl/filterable (^6.0), nvl/translatable (^6.0). Configure disks, scanner policy and owner capabilities. Resolve authorization from the host. S3/images/scanning need their documented optional features; factories do not create files.
Review the published common config, select one migration owner, and run schema preflight before existing-table upgrades. The installer does not enable features or run migrations. Follow the detailed installation and capability sections below before invoking a storage/provider operation.
Inject Nvl\Media\Contracts\MediaLibraryContract in a host service. After supplying the trusted inputs described above, the first public call is:
use Nvl\Media\Contracts\MediaLibraryContract; /** @var MediaLibraryContract $capability */ $result = $capability->paginate([], $actor);
Use the event catalog and Testing your app below. The suite getting-started guide provides a complete Comments host fixture; package archives retain their own local references.
For support, open an issue. For vulnerabilities, use private reporting. See Contributing.
See the installation and publishing guide for Composer setup, configuration, migration ownership, and agent skills.
Quick reference
| Item | Value |
|---|---|
| Installed through | composer require nvl/media:^6.0 |
| Module identifier | nvl/media |
| PHP namespace | Nvl\Media |
| Service provider | Nvl\Media\Providers\MediaServiceProvider |
| Configuration | config/nvl-media.php |
The suite's Laravel media module for secure uploads, private one-to-one files, reusable public assets, polymorphic ownership, image variations, localized metadata, centralized delivery, and safe lifecycle operations.
Purpose and boundaries
Media owns the complete lifecycle of binary assets: ingestion, validation, content-scanner invocation, storage identity, attachment, visibility, delivery, variations, metadata translations, reuse, replacement, and deletion. It does not own application-specific editor UI, CDN provisioning, a particular virus-scanner vendor, digital-rights policy, or role provisioning; those integrate through slots, policies, configured disks, events, focused contracts, and the optional Spatie Permission bridge.
Documentation
- PHP API: model trait, fluent adder, facade, injectable contract, slots, conversions, model helpers, DTOs, Actions, exceptions, and transaction semantics.
- HTTP API: authentication, authorization, every management and asset route, request validation, response schemas, status codes, errors, and multipart integration boundary.
- Configuration: every configuration group, default, environment variable, security boundary, and production recommendation.
- Extension contracts and events: container bindings, scanners, authorization, search, DNS, multipart gateways, lifecycle events, operational logs, and testing.
- Image variations and queues: presets, image drivers, workers, retries, and rollout.
- S3 and object storage: disks, IAM, private-at-rest delivery, multipart, CDN, and deployment.
- Command reference: doctor, reconciliation, pruning, regeneration, migration, flags, safety, and exit behavior.
- Upgrading, security policy, contributing, and changelog.
The optional Spatie permission bridge defaults to true through nvl-media.authorization.spatie_permission.enabled. Explicit grants use nvl.media.* names, including nvl.media.manage; holding only nvl.media.manage never automatically authorizes access to another owner's private Media. Ownership and host authorization contracts remain authoritative when the bridge is disabled.
For finite host lists, use ListOwnerMediaSlotsContract with an explicit MediaBatchAuthorization SQL adapter.
The reader returns safe localized original-asset slot DTOs for up to 100 owners and 20 slots, bounded before hydration.
Private item URLs remain null; obtain private Download links through the separately authorized delivery API.
The namespaced hasNvlMedia() host scope preserves selections and scopes independently of Spatie.
See batched slots and SQL authorization.
Requirements and installation
- PHP 8.4+
- Laravel 12–13
ext-curlfor DNS-pinned remote ingestionnvl/translatablefor localized media copy- an image driver supported by
spatie/image ext-gd, Imagick, or libvips with the encoders required by your chosen formatsleague/flysystem-aws-s3-v3is included for S3-compatible disks
composer require nvl/media:^6.0 php artisan migrate
Review the migration ownership choice below before migrating existing tables. If the application needs a different connection, table layout, or storage configuration, publish and edit config before migration. Agent skills are optional:
php artisan vendor:publish --tag=nvl-media-config php artisan vendor:publish --tag=nvl-media-skills
The package does not assume an application user model, UUID owner keys, storage provider, authorization package, or application middleware.
Choose exactly one migration owner:
-
Automatic vendor loading (default): leave
nvl-media.migrations.enabled=true, do not publishnvl-media-migrations, and runphp artisan migrate. -
Host-owned published migrations: publish
nvl-media-migrations, setnvl-media.migrations.enabled=falsebefore migrating, and maintain the published files as application migrations.php artisan vendor:publish --tag=nvl-media-migrations
Never run both sources. Laravel retimestamps files published through the migration tag. php artisan nvl:media:doctor reports a warning when automatic loading remains enabled and database/migrations contains a timestamp-independent name matching a package migration; --strict promotes that warning to failure.
English and Bulgarian media copy ships with the package. Publish conventional Laravel overrides with php artisan vendor:publish --tag=nvl-media-translations. Localized asset metadata remains database-backed through nvl/translatable.
Production support boundary
The major-five production path is PHP 8.4+, Laravel 12–13, PostgreSQL, an S3-compatible private bucket, Redis-backed cache/locks/queues, and a real MediaContentScanner. Multipart remains opt-in; when enabled, production additionally requires a recoverable gateway, central multipart locks, scanner attestation, and the PostgreSQL/Redis/S3 integration gate. Run php artisan nvl:media:doctor --production --strict against the deployed configuration before accepting traffic.
SQLite, local disks, array locks, and synchronous queues remain supported for development and the fast test suite. They do not prove the multi-node production guarantees.
Add media to a model
use Nvl\Media\Contracts\HasNvlMedia; use Nvl\Media\Traits\InteractsWithNvlMedia; use Nvl\Media\MediaRegistration; use Nvl\Media\Models\Media; final class Article extends Model implements HasNvlMedia { use InteractsWithNvlMedia; public function registerNvlMedia(MediaRegistration $media, ?Media $asset = null): void { $media->slot('gallery') ->publicReusable() ->useDisk('public') ->path('articles/{model_id}/gallery') ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp']) ->maxFileSize(8 * 1024 * 1024) ->onlyKeepLatest(20); $media->slot('datasheet') ->oneToOne() ->useDisk('local') ->acceptsMimeTypes(['application/pdf']); } }
publicReusable() means public + shared digest deduplication. oneToOne() means private + exclusive + single-file replacement. Lower-level isPublic(), shared(), exclusive(), and singleFile() remain available for deliberate combinations.
Upload through the model API
$image = $article ->nvlMedia()->add($request->file('image'), 'gallery') ->usingFileName('front.webp') ->withTags(['catalog', 'front']) ->withCustomProperties(['source' => 'admin']) ->withAssociationMeta(['role' => 'primary']) ->toLocale('bg') ->slot();
The adder optimizes first, then the authoritative ingestion pipeline detects MIME and SHA-256 from the materialized bytes, enforces the extension/MIME allowlist, dangerous multi-extension rejection, global and slot limits, SVG policy, and MediaContentScanner before any write. Storage names are cryptographically random and retain only the validated canonical extension. Upload, replacement, local/disk imports, remote sources, base64, and request uploads converge on this boundary; direct callers of UploadMediaAction cannot bypass it.
For one-to-one slots, the previous association/file is removed only after the replacement was stored and attached successfully. A rejected or failed replacement leaves the current file intact.
Additional sources:
$model->nvlMedia()->fromRequest('file')->slot('documents'); $model->nvlMedia()->fromUrl($url, 'image/jpeg')->slot('gallery'); $model->nvlMedia()->fromBase64($payload, 'image/png')->slot('gallery'); $model->nvlMedia()->fromString($text)->slot('documents'); $model->nvlMedia()->fromBinary($pdfBytes, 'report.pdf', 'application/pdf')->slot('documents'); $model->nvlMedia()->fromDisk($key, 'imports')->slot('documents');
Use $owner->nvlMedia()->all(), first(), firstUrl(), attach(), detach(), sync() and slot() for owner-scoped operations. For owner display lists use ListOwnerMediaSlotsContract with explicit actor and batch authorization; for host existence filtering use the authorized hasNvlMedia scope. nvlMediaAssociations is an internal storage relation and is not a consumer eager/lazy-loading API. These names coexist with a host's media() relation and with Spatie Media's upload and conversion hooks.
HasMedia and InteractsWithMedia are deprecated forwarding adapters retained through major 5. NVL-only owners may keep their existing calls during that window. A model using both libraries implements HasNvlMedia and uses InteractsWithNvlMedia for NVL, alongside Spatie's unchanged contract and trait; no trait aliases are required. tools/run-media-spatie-consumer.sh proves this with the real Spatie dependency in isolated test tooling.
Facade and injectable service API
The model trait is the shortest integration path. For application services, jobs, and controllers that should not be coupled to a specific owner instance API, import the package facade:
use Nvl\Media\Data\MediaFilter; use Nvl\Media\Enums\MediaAbility; use Nvl\Media\Facades\Media; $asset = Media::add($article, $request->file('image'), 'gallery') ->withTags(['catalog']) ->withoutVariations() ->upload(); $page = Media::paginate( new MediaFilter(search: 'catalog', perPage: 50), actor: $request->user(), ); abort_unless( Media::allows($request->user(), MediaAbility::Delete, $asset), 403, ); Media::delete($asset);
Nvl\Media\Facades\Media is a Laravel facade over MediaLibraryContract; it is not a truly static implementation. Consumers may inject MediaLibraryContract, fake the facade, replace the complete boundary, or replace focused upload/attach/detach/delete/reuse contracts. Those focused contracts are honored consistently by the trait, facade, lifecycle services, and package controllers.
The facade also exposes copy, fromRequest, fromUrl, fromBase64, fromString, fromBinary, fromDisk, findOrFail, urlIfExists, usages, attach, reuse, detach, replace, rename, relocate, updateMetadata, generateVariation, scan finalization, and persisted multipart operations. fromBinary accepts generated bytes plus a filename, detects MIME from the bytes, and enters the same validation/scanning pipeline as request uploads. Complex mutations keep their typed DTOs rather than accepting ambiguous option arrays.
Facade mutation calls are trusted application-service calls, like invoking an Action directly. They preserve validation, scanning, locks, transaction callbacks, and storage verification, but they do not authorize automatically. Ordinary uploads attribute the authenticated Eloquent actor when one is available. Jobs and console commands should call uploadedBy($actor) on the MediaAdder, or pass uploadedBy and uploadedByType to UploadMediaAction, when private ownership or uploader-scoped deduplication matters. Authorize at the HTTP/job boundary with Laravel policies or Media::allows() before invoking a mutation on behalf of a user.
The package intentionally does not register a global Media class alias because it would collide easily with application and Eloquent model names. Import the facade explicitly.
Remote URL ingestion is disabled by default. Enable it explicitly with NVL_MEDIA_REMOTE_SOURCES_ENABLED=true only in applications that accept remote sources. Ordinary request, local, disk, string, and base64 uploads do not enter the DNS/cURL path.
When enabled, remote and encoded sources are streamed into bounded package-owned temporary files and released in terminal finally paths. Remote requests allow only HTTP/HTTPS and configured ports, reject credentials and private/reserved A/AAAA results, pin cURL to the validated IP set, preserve TLS hostname verification, disable automatic redirects, revalidate every redirect, verify the connected IP, and enforce connect, total, redirect, and byte limits.
nvl-media.sources.remote.verify_connected_ip defaults to true. Disabling it is available for test fakes or a deliberately controlled custom transport; production doctor rejects that setting only when remote URL ingestion is enabled.
nvlMedia()->add($explicitLocalPath) owns that explicit source and removes it only after the real root transaction commits. nvlMedia()->copy() and preservingOriginal() retain it. Request uploads, package-created temporary files, and files not explicitly owned by the adder are never treated as caller-owned deletion targets; failures and rollbacks retain explicit local sources.
Direct multipart object-storage uploads
Multipart is disabled by default. Disabled deployments bind UnsupportedMultipartUploadGateway; enabling it selects the first-party recoverable S3-compatible gateway unless the application configures another implementation. The server persists encrypted provider state and every technical invariant in media_multipart_uploads. Signing, completion, and abort requests carry only the opaque server-issued upload ID plus the required part metadata or provider receipts.
InitiateMultipartUploadActionauthorizes an identifiable actor, validates disk, type, size, checksum and configured bounds, creates a random canonical object key, persists the session, then initiates the provider upload.SignMultipartPartActionreloads the actor-owned active session by ID and requires the exact part number, byte length, and lowercase SHA-256 checksum.- The client uploads parts directly to object storage.
CompleteMultipartUploadActiontakes the central session lock, rechecks state under a row lock, verifies every signed part, and requires provider path, object identity, size, and SHA-256 to match persisted state.AbortMultipartUploadActionaccepts the opaque upload ID, takes the same lock, and idempotently aborts an incomplete session.
Completion is idempotent by persisted session ID. Repeating a completed request returns the original media row without completing the provider upload again. If the provider completed but its response was interrupted, a recoverable gateway inspects the random object and resumes database finalization.
Every completed direct upload enters pending_scan, regardless of ordinary scanner defaults. An out-of-band scanner must call FinalizeMediaScanAction with MediaScanResultData, including clean/rejected state plus attested MIME, extension, size, SHA-256, and diagnostics. Only an exact clean attestation makes the media available and dispatches variations; a rejection or technical mismatch quarantines it.
Multipart limits include session duration, minimum/maximum part size, maximum parts, maximum object size, and central lock bounds. Schedule nvl:media:multipart:prune to idempotently abort expired provider uploads. Enable multipart in production only after the package’s PostgreSQL/Redis/S3 integration test passes against the target provider and nvl:media:doctor --production --strict reports a recoverable gateway, central lock store, and real scanner.
Run the production-stack proof locally against disposable PostgreSQL data, shared Redis, and the target S3-compatible provider. The command creates Media tables in the selected database and may create the configured bucket, so never point it at an application database or shared production bucket.
DB_CONNECTION=pgsql \ DB_HOST=127.0.0.1 \ DB_PORT=5432 \ DB_DATABASE=nvl_media_production_test \ DB_USERNAME=nvl \ DB_PASSWORD=nvl \ REDIS_HOST=127.0.0.1 \ REDIS_PORT=6379 \ MINIO_ENDPOINT=http://127.0.0.1:9000 \ MINIO_BUCKET=nvl-media \ MINIO_ACCESS_KEY=minioadmin \ MINIO_SECRET_KEY=minioadmin \ composer test:media-production
Omit the MINIO_* overrides when the documented local defaults match. The test
must report one passing test; a skip is not a production-stack proof.
Lifecycle states
Media persists pending_upload, pending_scan, quarantined, available, processing_variations, failed, and deleted. Only available or variation-processing media is usable. Failures retain bounded diagnostics for privileged operators; public DTOs omit scanner, quarantine, uploader, digest, disk, folder, and internal path details.
Reusable public assets
Public uploads deduplicate globally by content digest, disk, and visibility. Reuse an existing public asset without copying or uploading its physical object:
$media = $campaign->reusePublicMedia( media: $libraryAsset, collection: 'hero', metadata: ['placement' => 'homepage'], );
Private media cannot enter this API. A reused public asset attached to multiple owners is protected from ordinary global deletion. Remove it through an owner lifecycle method to detach only that usage, or call DeleteMediaAction::execute($media, force: true) for an intentional administrative global delete.
A public asset with multiple associations cannot be changed to private. Detach it to a single owner or create an explicit private copy first; this prevents a shared URL from silently changing its access contract for other consumers.
The first successful public upload owns canonical file-level tags and metadata. Later digest matches reuse that asset without mutating canonical metadata; placement-specific information belongs on association metadata.
Private files and uploader ownership
Private deduplication is scoped by both uploader type and uploader identifier. Authenticated Eloquent actors are stored polymorphically in uploaded_by_type and uploaded_by; integer, UUID, ULID, and string identifiers are supported.
Anonymous private uploads do not deduplicate by default:
'deduplication' => [ 'allow_anonymous_private' => false, ],
Use uploader() for the polymorphic relation. The package has no concrete user-model relationship or fallback uploader model.
Direct Actions
Application services may use explicit package actions:
$media = app(UploadMediaAction::class)->execute( file: $request->file('file'), disk: 'private', model: $owner, slot: (new MediaSlot('contract'))->oneToOne(), fileName: 'contract.pdf', isPublic: false, ); $association = app(AttachMediaAction::class)->execute( media: $media, model: $owner, collection: 'contract', );
Contracts are bound for the complete library facade plus upload, attach, detach, delete, and public reuse. Actions own mutation transactions; filesystem operations live behind media gateways/operators.
Owner-slot workflows
Application document concerns should declare policy and delegate lifecycle work to the four actor-aware owner-slot Actions. A KPO-style private PDF slot becomes:
use Nvl\Media\Contracts\HasNvlMedia; use Nvl\Media\Models\Media; use Nvl\Media\MediaRegistration; use Nvl\Media\Traits\InteractsWithNvlMedia; final class Report extends Model implements HasNvlMedia { use InteractsWithNvlMedia; public function registerNvlMedia(MediaRegistration $media, ?Media $asset = null): void { $media->slot('document') ->oneToOne() ->acceptsMimeTypes(['application/pdf']) ->maxFileSize(4 * 1024 * 1024); } }
The host creates MediaActorData from its authenticated principal and injects
the Actions rather than querying associations or copying custom properties:
use Nvl\Media\Actions\ClearOwnerMediaSlotAction; use Nvl\Media\Actions\CopyOwnerMediaSlotAction; use Nvl\Media\Actions\GetOwnerMediaSlotAction; use Nvl\Media\Actions\ReplaceOwnerMediaSlotAction; use Nvl\Media\Data\MediaActorData; $actor = new MediaActorData($user->getMorphClass(), (string) $user->getKey()); $current = $getOwnerMediaSlot->execute($actor, $report, 'document'); $document = $replaceOwnerMediaSlot->execute( $actor, $report, 'document', $stagedMediaId, $requestId, ); $copy = $copyOwnerMediaSlot->execute( $actor, $otherReport, 'document', $document->id, $copyRequestId, ); $clearOwnerMediaSlot->execute($actor, $report, 'document', $clearRequestId);
GetOwnerMediaSlotAction requires View; mutations require Associate.
Replacing with an existing private asset accepts an unassociated upload owned by
the actor, actor-owned staging associations, an authorized reusable public
asset, or an explicitly authorized ManageStaging adoption. A custom
fileAcceptor cannot be re-run for an already-persisted replacement because the
original UploadedFile is unavailable; upload directly into that slot instead.
Copy materializes and verifies the source, then passes a real UploadedFile
through canonical destination ingestion, so its custom acceptor is enforced.
Shared previous assets are detached and retained while referenced elsewhere.
Orphaned exclusive assets are deleted through the package lifecycle after the
real root transaction commits. Copy always creates a new Media identity, applies
the destination disk/visibility, preserves normalized tags, attributes the new
row to the actor, and copies only scalar keys allowlisted by
nvl-media.owner_slots.copy.metadata_keys. Add domain presentation/provenance keys
such as format explicitly; never allowlist credentials, provider payloads,
storage identity, redaction state, or association metadata.
Idempotency keys are optional UUIDs. Reuse the same key for retries of the exact actor/owner/slot/payload; mismatched reuse fails closed. Completed results replay without lifecycle effects, including after a newer request occupies the slot. The default same-connection ledger completes atomically with Media. A dedicated ledger connection uses immutable recovery checkpoints and remains retry-safe, but is a saga boundary. Workflows respect consumer-owned outer transactions: files, events, and split-ledger completion follow the actual root commit, while rollback keeps the prior slot and makes the operation retryable.
Run php artisan nvl:media:owner-slots:prune on a schedule. Doctor validates
the ledger schema/indexes and lifecycle bounds, atomic mutation-lock support,
and up to 100 owner type/slot pairs observed in the retained ledger.
Localized metadata
Media uses nvl/translatable for title, alt, caption, and description:
$payload = UpdateMediaPayload::validateAndCreate([ 'translations' => [ 'en' => [ 'title' => 'Front view', 'alt' => 'Blue shirt viewed from the front', ], 'bg' => [ 'title' => 'Изглед отпред', ], ], 'translationMode' => 'patch', ]); $media = app(UpdateMediaMetadataAction::class)->execute($media, $payload); $alt = $media->translated('alt', 'bg');
Patch preserves omitted locales; replace removes omitted locales. Filename, digest, disk, path, MIME type, size, visibility, tags, and technical metadata remain canonical.
Media automatically registers as media.assets in the central TranslationResourceRegistry.
Construct mutation DTOs through Laravel Data validation or another trusted boundary before invoking Actions.
Variations and optimization
Slots can define conversion presets:
$media->slot('gallery') ->publicReusable() ->addConversion('thumb', fn (ConversionDefinition $conversion) => $conversion ->fit('crop', 300, 300) ->format('webp') ->quality(82));
One upload may add or override named definitions:
$article->nvlMedia()->add($file, 'gallery') ->withVariations([ 'card' => ['width' => 640, 'height' => 360, 'fit' => 'crop', 'format' => 'webp'], 'zoom' => (new ConversionDefinition('zoom'))->width(1600)->format('webp'), ]) ->slot();
Definitions are normalized and persisted on the media row, so replacement and regeneration reproduce them. Label precedence is upload override, model, slot, then global. For a deduplicated shared asset, identical definitions are idempotent and new labels may be added; conflicting definitions for an existing label fail explicitly. withoutVariations() suppresses every automatic variation dispatch for that terminal upload. Asset delivery accepts named variations only and rejects arbitrary width, height, fit, quality, and format parameters.
The published configuration ships with:
thumb: an exact160×160cropped WebP.small: proportional WebP bounded to480×480, without upscaling.medium: proportional WebP bounded to960×960, without upscaling.optimized: proportional WebP bounded to1200×1200, without upscaling.
Presets and format profiles use ImagePreset, ImageFit, ImageFormat, and ImageCompression enums. WebP defaults to quality 82 and AVIF to 60; both are application settings, not universal targets. Lossless mode maps to quality 100 because that is the stable compression control exposed by the supported Spatie Image drivers. Unsupported formats fail during configuration/processing instead of silently preserving a misleading extension.
The default variation key is <source-hash>-<label>.<extension> for upgrade compatibility. A dimension-bearing pattern such as {basename}--{label}-{width}x{height}.{extension} is available. Labels and resolved filenames are validated before storage. Changing the naming pattern requires regenerating variations.
Run workers when conversions are queued. Variation generation is idempotent and missing association-driven conversions are regenerated when required.
See Image variations and queues for the complete configuration, encoder requirements, proportional-sizing behavior, custom presets, worker topology, retry rules, and deployment procedure.
S3-compatible storage
Set NVL_MEDIA_FILESYSTEM_DISK=s3, configure Laravel's filesystems.disks.s3, add s3 to nvl-media.allowed_disks, and leave the package asset routes enabled. The package streams remote sources into bounded temporary local files for image processing, writes results back through Flysystem, verifies source checksums, avoids S3 folder-marker objects, and preserves visibility during cross-disk copies.
S3 objects are private at rest by default—even when the Media record is public and reusable. Public means the record may be shared and served through the package's public delivery policy; it does not mean public-read ACL. This works with modern Bucket Owner Enforced buckets. Set NVL_MEDIA_S3_USE_ACL_VISIBILITY=true only when the bucket intentionally supports object ACLs.
See S3 and object storage for IAM, bucket, endpoint, URL, cache/CDN, checksum, multipart, and operational guidance.
Multiple disks and optional folders
Define filesystem disks in the host's config/filesystems.php. In config/nvl-media.php, keep root_folder as the fallback and override it per disk:
'public_disk' => 'public', 'private_disk' => 'private', 'allowed_disks' => ['public', 'private', 's3'], 'root_folder' => 'media', 'root_folders' => [ 'public' => 'media', 'private' => '', 's3' => 'assets/uploads', ],
Configure the named disks in Laravel first. A missing override inherits the global prefix; '' means no base folder. For originals directly at the private disk root, use asPublic(false)->toFolder('')->upload() or an empty slot path. A public upload uses public_disk; explicit builder, terminal and slot disk selections win. Both visibility defaults are null unless the host enables them, preserving existing behavior.
Uploads, replacements, variations, multipart completion, owner-slot copies, adoption, relocation and reconciliation use the selected disk's path rules. Tenant partitions remain mandatory. Whole-disk orphan deletion is refused; ordinary record-based deletion still works. Changing root configuration does not move existing objects. Use the controlled disk migration workflow before changing a live layout.
See Storage configuration for complete examples, precedence and migration guidance.
URLs and delivery
Use media URL/path APIs rather than concatenating disk paths:
$url = $media->buildUrl(['v' => 'thumb']); $publicUrl = $media->buildPublicUrl(['v' => 'thumb']); $temporaryUrl = $media->getTemporaryUrl(now()->addMinutes(5)); $nullableUrl = Media::urlIfExists($media);
The package separates public and signed/private routes, allowlists named variation parameters, emits public/private cache headers, and supports local or remote public delivery. Central public URLs include a content version so immutable caches are safe across replacement and regeneration. Original and variation responses use distinct ETags and honor normal, weak, wildcard, and comma-separated If-None-Match values.
Private URL generation is fail-closed: it returns a temporary signed package route or a temporary disk URL and never falls back to an unsigned object URL. Keep the private asset route enabled unless every private disk supports temporary URLs.
Route controls:
'routes' => [ 'api_enabled' => false, 'api_prefix' => 'nvl/api/v1', 'api_middleware' => ['api'], 'management_middleware' => ['auth', 'throttle:60,1'], 'assets_enabled' => true, 'assets_prefix' => 'nvl/media', ],
Applications may add Sanctum, verification, permissions, response envelopes, or throttles. No named host middleware is required.
Global administrators and Spatie Permission
The default MediaAuthorization grants cross-owner mutations only to the owning uploader. If the authenticated model exposes Spatie Permission's hasAnyRole and permission-checking methods, Media can add explicit cross-owner grants without requiring spatie/laravel-permission as a package dependency:
'authorization' => [ 'spatie_permission' => [ 'enabled' => true, 'global_roles' => ['admin', 'super-admin'], 'global_permission' => 'nvl.media.manage', 'ability_permissions' => [ 'list_all' => 'nvl.media.view-any', 'view' => 'nvl.media.view-any', 'download' => 'nvl.media.download-any', 'associate' => 'nvl.media.associate-any', 'mutate' => 'nvl.media.update-any', 'delete' => 'nvl.media.delete-any', 'reuse' => 'nvl.media.reuse-any', 'manage_staging' => 'nvl.media.manage-staging', ], ], ],
NVL_MEDIA_GLOBAL_ROLES=admin,super-admin is the environment shortcut for the role list. Role names are empty by default, so installing an authorization package cannot silently elevate a pre-existing role during an upgrade. The nvl.media.manage permission grants every Media ability; the *-any permissions are granular. nvl.media.manage-staging is the narrow cross-owner grant used when an owner-slot workflow adopts another actor's staged asset. Missing roles, permissions, guard mismatches, or an absent Spatie package fail closed and normal ownership policy continues.
Global access bypasses uploader ownership, including list scoping and private delivery, but does not bypass shared-asset reference integrity, scanner quarantine, mutation locks, or storage verification. Applications may disable the bridge or replace MediaAuthorization for a fully custom policy.
Retrieval and lifecycle
$gallery = $article->nvlMedia()->all('gallery'); $first = $article->nvlMedia()->first('gallery'); $article->nvlMedia()->detach($media, 'gallery'); $article->nvlMedia()->clear('gallery');
Shared media is detached while another owner still uses it and physically deleted only after its last association disappears. Soft-deleting an owner preserves media associations for restoration; force deletion follows configured cleanup behavior.
MediaQueryService provides filtered, paginated administrative reads and visibility scoping. Public media is visible; private media is limited to its typed uploader unless the actor has a configured management ability or global role.
Operations
php artisan nvl:media:doctor --production --strict --format=json php artisan nvl:media:adopt-spatie --source=media_spatie_legacy --format=json php artisan nvl:media:reconcile --production --disk=s3 --orphans php artisan nvl:media:regenerate --dry-run --preset=thumb --disk=s3 php artisan nvl:media:migrate-disk --from=public --to=s3 --dry-run php artisan nvl:media:multipart:prune --limit=500 php artisan nvl:media:owner-slots:prune --days=7 --chunk=500
Run storage health before and after a disk migration. Back up metadata and verify target-disk credentials before production moves.
nvl:media:reconcile is read-only by default. --orphans inventories paginated unreferenced objects below the selected disk prefix and active tenant partition; --cleanup-orphans is the explicit deletion switch, --older-than protects recent work, and production cleanup additionally requires --force. Objects with unreliable age remain report-only. Files referenced only by soft-deleted media are candidates while their database tombstones remain. Disk migration supports dry run, association scopes, records-only moves, and copy verification.
Every option, safety rule, exit status, and production sequence is documented in Command reference.
Database schema and adoption
Optional tenant ownership
Installing Media also installs the inert nvl/tenancy library; tenancy remains
disabled until the host explicitly selects and adopts the Media family. Tenant
assets, associations, variations, translations, multipart sessions, and
owner-slot operations use the canonical owner tenant. Platform catalog grants
authorize copy only: an import creates independent bytes, UUIDs, rows, and
immutable provenance, and later revocation or source deletion does not change a
committed tenant copy.
Run adoption only in maintenance from a reviewed mapping through prepare → bounded backfill → verify → activate. An interrupted run may resume after source or schema repair consistent with its immutable mapping. If the mapping changes, restore the pre-cutover backup and prepare a new reviewed run. Do not treat dropping ownership columns as rollback once duplicate names or copied assets exist. Cleanup is package-owned and bounded; verify physical paths before deleting any source or imported object.
Package-owned media, association, variation, multipart-session, owner-slot-operation, and translation rows use UUID primary keys. Uploader and owner morph identifiers are strings so integer, UUID, ULID, and string application keys remain compatible. The clean create migrations include composite indexes for visibility, uploader, disk, type and status listings by creation time; multipart indexes cover actor history, status/expiry, and completed media. The owner-slot ledger uniquely indexes UUID idempotency keys and indexes owner/slot and creation-time lookups. Its connection, table, processing lease, retention, and pruning chunk are configured under nvl-media.owner_slots.idempotency; Doctor checks its schema, lifecycle bounds, atomic lock store, and observed model/slot registrations. Expired processing attempts recover under a new operation UUID so stale workers cannot complete the replacement claim. A custom ledger connection is a recoverable saga boundary rather than a cross-database atomic transaction. Schedule nvl:media:owner-slots:prune to remove only expired terminal claims in bounded chunks.
Set nvl-media.migrations.enabled=false only while staging a legacy table whose canonical name would collide with the package migration. Rename that source, create the package schema, then run nvl:media:adopt-spatie without --apply. The command maps standard Spatie ownership columns into associations, preserves UUIDs or derives stable UUIDs from integer identifiers, accepts optional translation and variation tables, verifies every backing path, and reports source/matched counts. --apply is refused until the dry run has no mapping or path errors; it never drops the staged source tables and is idempotent by deterministic identifiers.
For in-place adoption, nvl-media.root_folder must describe the physical object layout, not the desired future layout. When persisted folder values already contain the complete path below the disk root, set NVL_MEDIA_ROOT_FOLDER= (empty) before dry-run and cutover. Otherwise physically move objects through nvl:media:migrate-disk and reconcile them. Doctor samples live Media rows against storage and reports root-folder drift before URLs are enabled. Soft-deleted diagnostic tombstones are excluded because canonical deletion may intentionally remove their objects.
A strict storage.persisted_paths failure for a live Media row is a data incident. Run read-only
Doctor and nvl:media:reconcile --production --orphans, verify disk/root/path,
hash, backups, and associations, and restore the original object when possible.
Use the relocation/migration API for intentional moves; never edit paths
directly or automate --cleanup-orphans to erase legacy discrepancies. An
unrecoverable object requires an explicit business decision before records or
associations are removed. See the recovery runbook.
Public and privileged DTOs
Use PublicMedia for public rendering. It exposes safe identity, type, localized copy, MIME/extension, URLs, responsive image sizes, and basic file size. It never exposes storage identity or security-boundary fields.
Authorized management APIs use privileged Data projections such as MediaLibraryItem and MediaManagementData. Keep management routes disabled unless the application intends to expose disk, folder, digest, uploader, association, and internal metadata to that authorized role.
Configuration checklist
- Define every usable filesystem disk and set
allowed_disks; the allowlist applies to both HTTP and direct action uploads. - Register only permitted associable model classes for API attachment. An empty
allowed_associable_typeslist disables all API association mutations. - Choose private/public delivery and signed URL lifetimes.
- Configure queue workers for variation generation.
- Keep queue
retry_after(or SQS visibility timeout) greater than the longest Media job timeout. - Use Redis or another central atomic lock store for
mutation_lock,deduplication_lock, and multipart session locks in multi-node deployments. - Run
nvl:media:doctor --production --strictafter changing disks, encoders, presets, sources, locks, scanner, multipart, or queue settings. - Keep SVG scanning and public-asset deletion protection enabled.
- Keep
file_typesto the smallestextension => string|list<string>server-detected MIME allowlist the application needs. - Configure
nvl-media.content_scannerwith a realMediaContentScannerfor untrusted uploads. The explicit default is a development-only no-op scanner. - Keep S3-compatible disks private at rest with
throw=true. - Keep multipart disabled unless the application needs direct uploads; when enabled, require the recoverable gateway, central locks, scanner attestation, pruning schedule, production doctor, and provider integration test.
- Disable package API routes if the application owns its own controllers.
- Run all migrations before accepting uploads.
TypeScript, agent skill, and quality
Media DTOs and enums register with Core's Data provider under Nvl.Media.*:
php artisan nvl:data:types:generate php artisan nvl:data:types:check
nvl-media-skills publishes current agent guidance into .agents/skills/nvl-media.
After upgrading Media, review local skill customizations and refresh that copy with php artisan vendor:publish --tag=nvl-media-skills --force. Suite-managed installations should use the Suite's managed skill publisher to update unchanged owned skills while retaining local edits. The skill covers per-disk prefixes, empty folders, visibility defaults and controlled migration.
composer install composer quality
composer quality verifies the manifest, Pint formatting, PHPStan at maximum strictness, direct dependency declarations, generated TypeScript freshness and compilation, and the isolated Testbench/Pest suite. Run Composer and npm security audits separately against their lockfiles. Compatibility verification covers Laravel 12/Testbench 10 and Laravel 13/Testbench 11 on the suite's PHP 8.4+ runtime. The local production integration command additionally requires PostgreSQL, Redis, and MinIO or equivalent S3-compatible storage.
The documentation coverage test keeps facade, trait, adder, slot, conversion, model-helper, management-route, contract, event, and top-level configuration references synchronized with their public source surfaces.
Supported PHP usage
The source @api declarations identify supported workflows, extension contracts, and value types. Public members marked @internal and untagged implementation types remain package-owned. Concrete Actions retain their existing constructors, qualifiers, and execute() signatures.
A package model returned or accepted by a public workflow is an identity/result handle. Use its declared type and getKey(), getKeyName(), getMorphClass(), getRouteKey(), getRouteKeyName(), is(), isNot(), and relationLoaded(). Read only explicitly declared in-memory @nvl-consumer-read fields; ordinary model PHPDocs and fillable attributes do not grant consumer reads. Obtain display projections through public reads. Persistence, additional model queries, relation access/loading, and generic model serialization are outside this contract. Host-model queries remain available, while traversal or aggregates of package capability relations require the package public reader or authorized adapter.
The current readable handle fields are Media: id, filename, extension, mime_type, size, is_public, visibility, status, revision, width, height, duration_ms, type, created_at, updated_at, deleted_at. All other package model handles have no readable attribute grant.
Shared owner identity
Declare a model once in config/nvl-core.php:
'owners' => [Article::class],
Enable this package capability separately in config/nvl-media.php:
'allowed_associable_types' => [Article::class], 'associable_mutation_abilities' => [Article::class => 'update'],
Owners implement HasNvlMedia and use InteractsWithNvlMedia; declare slots through registerNvlMedia() and access them through nvlMedia(). Preserve collection/slot declarations and authorize the configured mutation ability. Core registration does not add the model to this package's allowlist.
Laravel's getMorphClass() determines stored identity. These class declarations do not install host morph maps. Keep resolvers, handlers and authorization independent; use nvl:doctor --strict --format=json to review legacy alias mismatches or stored identity drift. See UPGRADING.md before changing the host's morph map.
Queue inheritance and shared diagnostics
nvl-media.queue.connection defaults to null. Media jobs use an explicit Media value first, then nvl-core.queue.connection, then Laravel's effective queue.default. NVL_MEDIA_QUEUE_CONNECTION remains an explicit configuration input. An explicit sync at the selected layer keeps synchronous processing; an empty string is invalid. Runtime configuration and cached configuration use the same resolution.
Run php artisan nvl:doctor --strict --format=json to aggregate the checks from loaded package providers, or php artisan nvl:media:doctor --strict --format=json for the Media-specific report. Production workers still require an appropriate host deployment and queue configuration.
Shared infrastructure options
nvl-media.queue.name also defaults to null: it inherits nvl-core.queue.name, then the queue name configured for the selected Laravel connection. Set NVL_MEDIA_QUEUE=media or nvl-media.queue.name = 'media' when workers intentionally consume a dedicated Media queue. Existing explicit published names remain overrides.
Media locks use nvl-media.locks.store, then nvl-core.locks.store, then cache.default. Independent locks.mutation.store, locks.deduplication.store, and locks.multipart.store overrides preserve operation-specific stores. Historical mutation_lock.store, deduplication_lock.store, and multipart.lock.store remain supported for one major cycle and are reported by Doctor. Their duration/wait settings remain operation-specific.
Next major: isolated schema identities
Use nvl-media.tables.<logical-key> for every table and nvl-media.connection for its database connection. Null connection inherits nvl-core.connection, then Laravel's default. Tables are resolved at runtime by the package table definition helper.
| Logical key | New default | Previous name |
|---|---|---|
media |
nvl_media_media |
px_media |
associations |
nvl_media_associations |
px_media_associations |
image_variations |
nvl_media_image_variations |
px_media_image_variations |
i18n |
nvl_media_i18n |
px_media_i18n |
multipart_uploads |
nvl_media_multipart_uploads |
px_media_multipart_uploads |
owner_slot_operations |
nvl_media_owner_slot_operations |
px_media_owner_slot_operations |
tenant_grants |
nvl_media_tenant_grants |
px_media_tenant_grants |
tenant_grant_locks |
nvl_media_tenant_grant_locks |
px_media_tenant_grant_locks |
tenant_adoption_copies |
nvl_media_tenant_adoption_copies |
px_media_tenant_adoption_copies |
Migration filenames contain nvl_media_. Existing installations must complete the upgrade in UPGRADING.md before running new migrations. A pending creator rejects an existing target before that owned migration runs; use nvl:schema:preflight for an explicit whole-batch check; legacy storage with old history needs an ownership decision.
Owned cache and lock identities follow nvl:<package>:<purpose>:…. Mutation, deduplication, and multipart locks use nvl:media:mutation:, nvl:media:deduplication:, and nvl:media:multipart:. File existence entries use nvl:media:file-existence: instead of media_exists:. Invalidation touches only the owned entry; generic host keys are never read, copied, or removed. See UPGRADING for coordinated worker and lock lease cutover.
Canonical configuration ownership
Use nvl-media settings in config/nvl-media.php and canonical package environment names. Old generic roots are foreign unless an upgrading NVL host explicitly selects them in Core's default-off compatibility. Canonical false/null/empty values win; no old roots are populated or written back. Keep logical package/resource IDs unchanged. Review Core's rename inventory and cache/worker cutover.
Replaceable workflows and scripted uploads
Inject MediaLibraryContract for the complete Media API, or the focused workflow
contract (for example ReplaceOwnerMediaSlotContract) for one operation. Selected
Action contracts retain the native execute() arguments and results; the default
implementations retain their concrete constructors and final/readonly qualifiers.
Defaults use conditional bindings so an earlier host binding remains authoritative.
MediaLibraryContract and MediaCatalogImport retain scoped defaults.
The eight library source methods return MediaUploadBuilder: add, copy,
fromRequest, fromUrl, fromBase64, fromString, fromBinary, and fromDisk.
The real library still returns MediaAdder covariantly. Type consumer orchestration
against the builder interface when replacement is required.
use Nvl\Media\Contracts\MediaLibraryContract; use Nvl\Media\Models\Media; use Nvl\Media\Testing\FakeMediaLibrary; use Nvl\Media\Testing\FakeMediaUploadBuilder; use Nvl\Support\Testing\FakeCall; // $app is the explicit application container; $owner is an in-memory media owner. $asset = new Media; $asset->forceFill(['id' => 'example-asset', 'is_public' => false]); $builder = (new FakeMediaUploadBuilder)->willReturn('upload', $asset); $fake = FakeMediaLibrary::fake($app)->willReturn('fromString', $builder); $result = $app->make(MediaLibraryContract::class) ->fromString($owner, 'document bytes') ->usingSlot('document') ->asPublic(false) ->upload(); $builder->assertCalled('upload', fn (FakeCall $call): bool => $call->arguments['options']['slot'] === 'document' && $call->arguments['options']['public'] === false);
Library scripts use the actual library method name and return its declared native
result. Every call consumes one FIFO result or exception and is recorded before
throwing or reporting exhaustion. Use willThrow($method, $exception) for failures,
calls($method) for ordered immutable FakeCall records, and
assertCalled($method, $predicate, $times) for exact counts. Predicates receive a
FakeCall; zero is a valid expected count and negative counts fail. Nullable and
void successes require an explicit willReturn($method, null). Authorization and
URLs must be explicitly scripted, including false or null; they are never inferred.
Script each fresh FakeMediaUploadBuilder under its own upload, slot, or
slotOnCloudDisk terminal name. Each source entry initializes its returned fake
builder exactly once from the actual source arguments and owner identity. Reusing
that builder in another library entry fails without overwriting the original
context. An arbitrary host MediaUploadBuilder is returned unchanged. Repeated
terminals consume new scripts, recording one complete request per attempt.
Terminal FakeCall::arguments contains arguments (the native terminal arguments),
source (method and its original named arguments, excluding owner), owner
(model, native id, and morph type), and an options snapshot. Options retain
slot, pendingSlot, resolved collection, fileName, inert fileNameSanitizer,
preserveOriginal, customProperties, appended tags, locale, order, disk,
folder, nullable public, keyed variations, independent skipVariations,
associationMeta with the resolved slot, allowDuplicates, uploaderId, and
uploaderType. Later setters do not change earlier array snapshots. Model objects
and closures retain identity; they are not serialized or executed.
The fake performs no source reads, decoding, downloads, filesystem mutations,
scanning, SQL, or job dispatch, and does not resolve owner-slot defaults. copy
presets preservation, null slot names fall back while empty names remain empty,
and a terminal disk override remains for later terminals. Variation maps retain
supplied keys; property, metadata, and tag merges follow native array_merge.
Uploader identifiers use the native string conversion; owner keys retain their
native type. The cloud disk is captured from the explicit installation container,
with native s3 fallback, rather than a global application.
Install before resolving consumer services. Installation binds the supplied
container's MediaLibraryContract instance and clears that facade accessor's cached
root; it does not change Laravel's global facade application. A fake installed
after normal scoped registration is forgotten on forgetScopedInstances(); a
pre-discovery host instance preserved by scopedIf remains host-owned. Scope flushes
do not automatically clear Laravel facade caches. Use a separate fake per container.
Testing your app
Inject the supported contract rather than constructing its concrete Action or querying package tables. Replace Nvl\Media\Contracts\MediaLibraryContract in Laravel's native container for a host-workflow test:
use Nvl\Media\Contracts\MediaLibraryContract; $double = Mockery::mock(MediaLibraryContract::class); $this->app->instance(MediaLibraryContract::class, $double); // Configure the exact paginate arguments and documented return value for your host case.
The package's conditional native binding preserves host substitutions. Production uses the real contract; test doubles do not prove its storage/authorization behavior.
A detached fixture for a returned identity/data handle is:
use Nvl\Media\Models\Media; $fixture = Media::factory()->withoutParents()->make();
Ordinary make() may persist declared package parents. withoutParents()->make() disables parent expansion/admission for detached fixtures; use explicit persisted parents/owners and matching effective connections for a real create(). Factories do not authorize workflows, call Stripe, create backing Media objects or publish Template artifacts. Enabled tenancy requires explicit admitted persisted tenants/parents. Your host test installation supplies Faker; no test runner is a runtime package dependency.
Nvl\Media\Testing\FakeMediaLibrary::fake($this->app) installs the complete interface fake and clears the matching facade root. Script library and builder terminal results explicitly; no real upload/source resolution/URL generation occurs. Use ListOwnerMediaSlotsContract for batched owner slots and the documented eager-load adapter for host queries.
Use Laravel Event::fake(), Queue::fake(), Mail::fake() or Storage::fake() only for the effects the host test intends to isolate. Use real commits/listeners for timing proof. Add the optional Core consumer boundary rules to host PHPStan:
includes: - vendor/nvl/core/support/consumer-audit.neon parameters: nvlConsumer: testPaths: [tests] tableNames: [] exceptions: []
Rules read installed public metadata without suite boot. They flag internal symbols, package model queries/writes, capability relations and owned tables; they cannot prove dynamic code or runtime authorization. Exact exceptions require file, identifier, symbol, and a documented reason. The published 5.x family is verified through the local Dagger release gate on PHP 8.4/Laravel 13, including owning suites, MySQL/PostgreSQL persistence contracts and sealed Tenancy consumers. Fresh public Composer installation, discovery and configuration/route caching are verified. PHP 8.5, Laravel 12, MariaDB and the full independent archive matrix require separate evidence. See the verification and release policy.
Shipped factory states
These runtime builders keep Laravel's native Factory API. The listed methods name explicit supported parent/owner/lifecycle states; follow each factory's native admission requirements. Detached examples above do not assert persistence validity.
| Factory | Explicit states |
|---|---|
MediaAssociationFactory |
forMedia(Media $parent), forOwner(Model $owner) |
MediaFactory |
Native Factory states only |
MediaImageVariationFactory |
forMedia(Media $parent) |
MediaTenantGrantFactory |
forMedia(Media $parent), forRecipient(TenantId $recipient) |
MediaTranslationFactory |
forMedia(Media $parent) |
Error codes and events
All recognized package failures implement Nvl\Support\Contracts\PackageException; only RespondableException opts into safe response metadata. Keep native PHP programmer errors and Laravel/SDK exceptions distinct. The optional PackageExceptionRenderer is registered by the host in withExceptions; it leaves unrelated, marker-only and non-JSON handling to the host. Its JSON envelope is {message:string, code:string, context:object}. Request locale is host-owned; diagnostics/previous exceptions are not public copy. Event schemas and source connections are documented in events.
The table lists enum discriminators, including any successful codes retained for compatibility. A code is not itself an HTTP status; the throwing exception's suggestedStatus() is authoritative, especially legacy/custom constructors. Empty context renders as {}; only documented JSON-safe context is presented.
| Code | Suggested status | Public context | Translation key |
|---|---|---|---|
file_unacceptable_for_collection |
422 | Declared safe scalar/array map; otherwise {} |
nvl-media::responsecode.file_unacceptable_for_collection |
conversion_failed |
500 | Declared safe scalar/array map; otherwise {} |
nvl-media::responsecode.conversion_failed |
disk_not_defined |
500 | Declared safe scalar/array map; otherwise {} |
nvl-media::responsecode.disk_not_defined |
media_upload_failed |
422 | Declared safe scalar/array map; otherwise {} |
nvl-media::responsecode.media_upload_failed |
media_in_use |
409 | {media_id:string, association_count:int} | nvl-media::responsecode.media_in_use |
storage_unavailable |
500 | Declared safe scalar/array map; otherwise {} |
nvl-media::responsecode.storage_unavailable |
updated |
Exception-defined; see suggestedStatus() |
Declared safe scalar/array map; otherwise {} |
nvl-media::responsecode.updated |
deleted |
Exception-defined; see suggestedStatus() |
Declared safe scalar/array map; otherwise {} |
nvl-media::responsecode.deleted |
batch_read_unavailable |
500 | Declared safe scalar/array map; otherwise {} |
nvl-media::responsecode.batch_read_unavailable |
media_not_reusable |
422 | Declared safe scalar/array map; otherwise {} |
nvl-media::responsecode.media_not_reusable |
s3_dependency_unavailable |
500 | Declared safe scalar/array map; otherwise {} |
nvl-media::responsecode.s3_dependency_unavailable |
operation_failed |
Exception-defined; see suggestedStatus() |
Declared safe scalar/array map; otherwise {} |
nvl-media::responsecode.operation_failed |
Operational logging
nvl-core.logging defaults to channel nvl, normal verbosity and a CSV quiet override. Configure package channel/verbosity overrides under packages; verbosity is quiet, normal or verbose. Warnings/errors survive every setting. The absent nvl channel becomes a stack of the host default; a configured host channel wins. Do not configure a self-referential stack. Doctor diagnoses missing/cyclic channels without logging to them. Stable nvl.<package>.<operation>.<result> keys carry bounded diagnostics, never retained tenant/job context. CSV logs one failed-row warning summary per chunk; row details require verbose mode and contain no raw row values.
License
Released under the MIT License.
Owner capability relations metafields(), comments(), nvlMediaAssociations() and termables() are enforced statically, not at runtime. They remain ordinary Eloquent relations for package internals. Enable vendor/nvl/core/support/consumer-audit.neon in the host PHPStan configuration, and use public workflow contracts and batch readers in consumer code. The static rules do not replace runtime authorization.