edulazaro / laracrate
Polymorphic file storage for Laravel with R2/S3 direct upload, granular access control, sensitive content streaming and image conversions. Zero dependencies beyond Laravel core.
Requires
- php: >=8.2
- aws/aws-sdk-php: ^3.300
- edulazaro/laractions: ^1.0
- intervention/image: ^3.0
- laravel/framework: >=12.0
- league/flysystem-aws-s3-v3: ^3.0
Requires (Dev)
- orchestra/testbench: ^9.0
- phpunit/phpunit: ^11.0
- dev-main
- 0.9.3
- 0.9.2
- 0.9.1
- 0.9.0
- 0.8.1
- 0.8.0
- 0.7.0
- 0.6.1
- 0.6.0
- 0.5.4
- 0.5.3
- 0.5.2
- 0.5.1
- 0.5.0
- 0.4.30
- 0.4.29
- 0.4.28
- 0.4.27
- 0.4.26
- 0.4.25
- 0.4.24
- 0.4.23
- 0.4.22
- 0.4.21
- 0.4.20
- 0.4.19
- 0.4.18
- 0.4.17
- 0.4.16
- 0.4.15
- 0.4.14
- 0.4.13
- 0.4.12
- 0.4.11
- 0.4.10
- 0.4.9
- 0.4.8
- 0.4.7
- 0.4.6
- 0.4.5
- 0.4.4
- 0.4.3
- 0.4.2
- 0.4.1
- 0.4.0
- 0.3.1
- 0.3.0
- 0.2.5
- 0.2.4
- 0.2.3
- 0.2.2
- 0.2.1
- 0.2.0
- 0.1.7
- 0.1.6
- 0.1.5
- 0.1.4
- 0.1.3
- 0.1.2
- 0.1.1
- 0.1.0
This package is auto-updated.
Last update: 2026-06-02 23:42:56 UTC
README
Polymorphic file storage for Laravel with direct upload to R2/S3, granular access control, sensitive content streaming, automatic image variants, video and PDF previews, per-variant watermarking, multipart uploads, text extraction, and vector embeddings.
Table of contents
- Philosophy
- Installation
- Data model
- Configuration
- Using it from your model
- Processing pipeline
- Variants
- Upload modes
- HTTP endpoints
- Sensitive content
- Artisan commands
- Optional Livewire component
- Full API
- Tests
- Dependencies
- License
Philosophy
- Backend agnostic to the frontend. The core has zero dependency on Livewire or Alpine. It exposes endpoints, a trait, and a service.
- Reuses Laravel's
Storage::disk(). Disk credentials live inconfig/filesystems.php(single source of truth). The package does not duplicate configuration. - Pipeline of Actions. Every operation is an isolated class (
edulazaro/laractions), testable and queueable. - Async processing. Variants, video and PDF previews, text extraction, embeddings, all run on the queue. The user upload is instant.
- 3 access modes per collection:
public(direct CDN),signed(temporary signed URL),stream(controller with audit and viewer bind). path = full keyconvention. Thepathfield on a File row stores the complete object key in the disk (directories, filename, extension). Thenamefield is denormalization ofbasename($path).
Installation
composer require edulazaro/laracrate php artisan vendor:publish --tag=laracrate-config php artisan migrate
migrate creates 3 tables, all with the laracrate_ prefix:
laracrate_files, the main table for top-level files and variants.laracrate_file_contents, chunks of extracted text and embeddings (opt-in).laracrate_multipart_uploads, active multipart upload sessions.
The laracrate_ prefix avoids clashing with legacy files tables that exist in many Laravel apps.
Disks in config/filesystems.php
Add the disks you intend to use (R2/S3 for real storage, local for dev):
'media' => [ 'driver' => 's3', 'bucket' => env('R2_BUCKET_MEDIA'), 'endpoint' => env('R2_ENDPOINT'), 'use_path_style_endpoint' => true, // ... ], 'documents' => [ 'driver' => 's3', 'bucket' => env('R2_BUCKET_DOCUMENTS'), // ... ],
Data model
Table laracrate_files
47 core columns plus JSON:
id, slug (ulid)
parent_id, variant (variants/preview hierarchy)
fileable_type/id (polymorphic — what the file belongs to)
creator_type/id (polymorphic — who uploaded it)
owner_type/owner_id (polymorphic — for-whom; falls back to creator)
tenant_type/id (polymorphic — multi-tenant scope)
disk, path, name, original_name, extension, mime_type, size, digest
context, collection, type (image/video/audio/document), category
access (public/signed/stream), visibility, sensitive, is_encrypted
title, description, label, default, position, published, is_verified
duration, width, height, bitrate, sample_rate
summary (optional, set by extractors/LLMs)
metadata (json)
processing_status, processing_error, processing_started_at
processing_extractor, processing_provider, processing_model (audit trail of the pipeline run)
mysql_indexed_at, meili_indexed_at, storage_indexed_at (chunks-backend index trackers)
downloads_count, last_downloaded_at
timestamps + softDeletes
The three *_indexed_at columns let a single deployment migrate between ChunkStore backends (mysql → meilisearch → custom) without re-running the whole pipeline: a re-index job only re-syncs files where the target backend's timestamp is null or older than updated_at.
Visibility values come from the FileVisibility enum; processing states from ProcessingStatus.
Auxiliary tables
laracrate_file_chunks— one row per chunk produced by the embeddings pipeline. Each row carriestext,embedding(vector),context(optional discriminator when a single extraction yields multiple sections, e.g. OCR text + visual description), andsummary/descriptionif the extractor produced them. Indexed by FULLTEXT for keyword search; cosine similarity is computed in PHP byMysqlChunkStore, or pushed to Meilisearch byMeilisearchChunkStore(see### chunksbelow).laracrate_multipart_uploads— active multipart upload sessions for S3/R2. Typical lifetime minutes to hours. Thelaracrate:abort-stale-multipartcron aborts those pastexpires_at. Status enum:MultipartUploadStatus.laracrate_file_slots— optional structured "slots" with validation rules (max files, accepted mime types, required) for workflows like "upload your DNI: PDF only, 1 file max". TheFileSlotmodel wraps the row; theHasFilestrait exposes helpers to fill, validate, and check slot completion.laracrate_tenant_buckets— per-tenant bucket/disk overrides for multi-tenant or BYOA (bring-your-own-account) setups. A row maps(tenant_type, tenant_id, base_disk)to a concrete bucket + credentials. Resolved byStorageManager::diskFor()via the tenant relation on the File. Useful when each customer's data must live in their own S3 account for compliance or cost attribution.
Each table has a matching Eloquent model under EduLazaro\Laracrate\Models\ — FileChunk, MultipartUpload, FileSlot, TenantBucket — that you can query directly when you need fine-grained access (e.g. listing chunks for debugging, aborting a specific multipart session by id, attaching slot rules to a workflow).
Key concepts
pathis the full object key in the disk. It is not concatenated withname. Recommended access:$file->key(an accessor that doesltrim($file->path, '/')).nameis denormalization of the basename (with extension). Useful for queries and display, never concatenated.parent_idandvariant: any child File (thumbnail, preview, transcoded) hasparent_idpointing to its parent andvariantcarrying the role (thumbnail,medium,preview,display...). Recursive: a video preview has its own child variants.- 3 orthogonal polymorphic relations:
fileable(what it belongs to),creator(who created it),tenant(multi-tenant scope). access:publicproduces a direct CDN URL,signedproduces a signed URL with TTL,streamproduces a package route with per-request re-validation.processing_status:pending,processing,completed,failed. EnumEduLazaro\Laracrate\Enums\ProcessingStatus.
Configuration
Everything lives in config/laracrate.php (published with vendor:publish).
default_collection and default_context
Schema defaults applied when a File row is inserted without specifying them.
'default_collection' => 'default', 'default_context' => 'default',
defaults, defaults per file type
These apply to every collection unless overridden. Each type defines accepted mime types, max size, quality, max dimensions, and default variants.
'defaults' => [ 'image' => [ 'accepted_mime_types' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], 'accepted_extensions' => ['jpeg', 'jpg', 'png', 'gif', 'webp'], 'max_file_size' => 10240, 'format' => 'webp', 'quality' => 90, 'variant_quality' => 85, 'max_width' => 1920, 'max_height' => 1080, 'variants' => [ 'thumbnail' => ['width' => 300, 'height' => 300], 'medium' => ['width' => 800, 'height' => 800], 'large' => ['width' => 1600, 'height' => 1600], ], ], 'document' => [ 'accepted_mime_types' => ['application/pdf', 'application/msword', /* ... */], 'accepted_extensions' => ['pdf', 'doc', 'docx', 'xls', 'xlsx'], 'max_file_size' => 20480, ], 'audio' => [/* ... */], 'video' => [/* ... */], ],
collections, definition of each collection
Each collection sets disk, access mode, accepted types with their config, and optionally single, sensitive, encrypt, ttl_hours, quota_bytes, component, placeholder.
'collections' => [ 'avatar' => [ 'disk' => 'media', 'access' => 'public', 'single' => true, // only 1 file per owner 'component' => 'user-avatar', // default blade component (optional) 'types' => [ 'image' => [ 'variants' => [ 'small' => ['width' => 64, 'height' => 64, 'fit' => true], 'medium' => ['width' => 128, 'height' => 128, 'fit' => true], ], ], ], ], 'identity' => [ 'disk' => 'documents', 'access' => 'stream', 'sensitive' => true, // bind URL to the user 'encrypt' => true, // encrypt binary at rest 'types' => [ 'image' => [ 'variants' => [ 'thumbnail' => ['width' => 300, 'height' => 300], // no watermark 'display' => ['width' => 1200, 'watermark' => true], // watermarked ], ], 'document' => [ 'preview' => ['page' => 1, 'width' => 2000], ], ], ], 'temp_uploads' => [ 'disk' => 'media', 'access' => 'public', 'ttl_hours' => 24, // purged via command ], ],
Rules for types:
- An allowlist of which types the collection accepts, plus the config of what to do with each.
- Each entry can be a bare string (
'image', inherits global defaults) or an array ('image' => [overrides]). variantsalways live inside a type (types.image.variants).previewfor document and video produces a special variant; its own child variants go inpreview.variants.- The global type defaults are recursively merged with the collection override. You only declare what you want to change.
Granular per-model config (optional)
The same collection name can serve several models with different config per model, using an optional models block. The block is keyed by morph alias (or FQCN if you don't use a morph map). Each entry is merged on top of the base.
'documents' => [ // base — shared by every model that uses this collection 'disk' => 'documents', 'access' => 'signed', 'types' => [ 'document' => ['preview' => ['page' => 1, 'width' => 1600]], ], 'models' => [ 'case' => [ // inherits everything above, plus: 'path' => 'cases/{slug}/documents', ], 'organization' => [ 'path' => 'orgs/{handle}/documents', // override puntual: en orgs no queremos preview 'types' => [ 'document' => ['preview' => false], ], ], ], ],
Semantics:
- Without
models→ flat config, any model that uses the collection gets the same behavior (legacy / default). - With
models→ the collection is restricted to those aliases; any other model throwsEduLazaro\Laracrate\Exceptions\CollectionNotAllowedForModel. - Override is
array_replace_recursiveon top of the base. Scalars get replaced; nested arrays merge key by key (declare the full array on a key when you want a wholesale swap). - The
modelskey itself is stripped from the resolved output — callers see a normal flat array. - Tooling that iterates collections without a model context (e.g.
laracrate:purge-expired) gets the base config without themodelsblock.
placeholders, fallback when there is no file
Resolution order (most specific to most general):
config('laracrate.collections.{name}.placeholder')config('laracrate.placeholders.{type}')config('laracrate.placeholders.default')
'placeholders' => [ 'default' => '/img/laracrate/file.svg', 'image' => '/img/laracrate/image.svg', 'video' => '/img/laracrate/video.svg', 'audio' => '/img/laracrate/audio.svg', 'document' => '/img/laracrate/document.svg', ],
Each slot accepts a fixed string or a dynamic closure:
'image' => fn ($collection, $type, $model) => "/api/avatars/{$model->id}.svg",
urls, URL strategy
'urls' => [ 'signed_ttl' => 5, // signed URL TTL in minutes (R2) 'signed_cache_ttl' => 4, // server-side cache TTL of the signed URL 'sensitive_redirect_ttl' => 10, // ultra-short TTL after validation (seconds) 'route_signed_ttl' => 15, // HMAC TTL for /files/{slug}/stream (minutes) 'bind_to_user' => true, // tie the URL to the current viewer when sensitive ],
policies, bridge to Laravel's Gate
'policies' => [ 'register_gate' => true, ],
When register_gate is on you can use the native ergonomics:
@can('view', $file) $user->can('update', $file) $this->authorize('delete', $file) Route::middleware('can:view,file')
Mapping: view/update/delete go to the registry's canView/canEdit/canDelete.
stream, streaming endpoints
'stream' => [ 'route_prefix' => 'files', 'route_name_prefix' => 'laracrate.files', 'middleware' => ['web', 'auth'], 'increment_downloads' => true, 'log_access' => true, ],
status, polling endpoints
'status' => [ 'route_prefix' => 'laracrate/files', 'middleware' => ['web', 'auth'], ],
Endpoints:
GET /laracrate/files/{slug}/status, status of a single file.POST /laracrate/files/status, batch (multiple slugs).
multipart, large uploads
'multipart' => [ 'threshold' => 100 * 1024 * 1024, // 100 MB; the frontend decides when to use multipart 'part_size' => 10 * 1024 * 1024, // 10 MB per part (S3 minimum is 5 MB) 'expire_minutes' => 60, // multipart session TTL 'url_ttl_minutes' => 60, // presigned URL TTL per part 'route_prefix' => 'laracrate/multipart', 'middleware' => null, // null inherits from uploads ],
image, image processing
'image' => [ 'driver' => 'imagick', // 'imagick' (recommended) or 'gd' 'optimize_originals' => false, // re-encode the original to webp with max dims 'max_width' => 1920, 'max_height' => 1920, 'quality' => 85, ],
video, transcoding
'video' => [ 'max_width' => 1920, 'max_height' => 1920, 'bitrate_kbps' => 2500, ],
Requires ffmpeg and ffprobe on the server's PATH.
encryption, encryption of sensitive binaries
'encryption' => [ 'driver' => 'laravel', ],
If a collection sets 'encrypt' => true, the binary is encrypted with EncryptFileAction before being uploaded to the backend, and decrypted on the fly when served by StreamFileController.
embeddings, text extraction and vectors
'embeddings' => [ 'enabled' => false, 'provider' => 'openai', 'api_key' => env('LARACRATE_EMBEDDINGS_API_KEY'), 'model' => env('LARACRATE_EMBEDDINGS_MODEL', 'text-embedding-3-small'), 'dimensions' => 1536, 'chunk_size' => 1000, 'chunk_overlap' => 100, 'batch_size' => 16, // Fallback chain of text extractors. Run in order; if one returns less // than `min_text_per_file` chars, the next is tried. Empty = built-in // defaults (PdfTextExtractor + PlainTextExtractor). 'extractors' => [ // \EduLazaro\Laracrate\Extractors\PdfTextExtractor::class, // \EduLazaro\Laracrate\Extractors\OcrPdfTextExtractor::class, // \EduLazaro\Laracrate\Extractors\PlainTextExtractor::class, ], 'min_text_per_file' => 100, ],
Per-collection activation:
'collections' => [ 'documents' => [ 'extract_text' => true, 'embed' => true, // ... ], ],
Custom provider:
// AppServiceProvider::register() $this->app->bind( \EduLazaro\Laracrate\Contracts\EmbeddingProvider::class, \App\Embeddings\MyCustomProvider::class );
Custom text extractor:
// Option A: declarative, via config (recommended). 'embeddings' => [ 'extractors' => [ \EduLazaro\Laracrate\Extractors\PdfTextExtractor::class, \App\Extractors\MyOcrExtractor::class, ], ], // Option B: imperative, registered at boot. $registry = app(\EduLazaro\Laracrate\Support\TextExtractorRegistry::class); $registry->add(new \App\Extractors\MyOcrExtractor());
Bundled implementations:
OpenAiEmbeddingProvider(default).NullEmbeddingProvider(no-op for testing).PdfTextExtractor— PDFs viasmalot/pdfparser, native text only.PlainTextExtractor— text/* files.OcrPdfTextExtractor— OCR fallback for scanned PDFs (Vision LLM), configured below.OcrImageTextExtractor— OCR for image files (JPG, PNG, HEIC) via Anthropic Claude or OpenAI Vision.AudioTranscribeExtractor— transcribes audio (mp3, wav, ogg, m4a, etc.) via OpenAI Whisper.VideoTranscribeExtractor— extracts audio with ffmpeg, transcribes via Whisper, optionally adds visual frame descriptions via Vision LLM.
Audio/video/image extractors call paid APIs — wire them in the extractors chain explicitly when you need them and check pricing for your workload.
chunks, persistence and search backend
Chunks produced by the embeddings pipeline (text split + vector) need a place
to live. Laracrate ships two ChunkStore implementations and lets you wire a
third one yourself.
'chunks' => [ 'driver' => env('LARACRATE_CHUNKS_DRIVER', 'mysql'), ], 'meilisearch' => [ 'index' => env('LARACRATE_MEILISEARCH_INDEX', 'laracrate_file_chunks'), 'embedder' => env('LARACRATE_MEILISEARCH_EMBEDDER', 'default'), ],
Drivers:
-
mysql—MysqlChunkStore. Persists tolaracrate_file_chunkswith a FULLTEXT index for keyword search and cosine similarity computed in PHP over the candidate pool. Zero external dependencies. Scales fine up to roughly 5K chunks per scope. -
meilisearch—MeilisearchChunkStore. Syncs chunks to a Meilisearch index with user-provided embeddings, enabling native hybrid search (BM25 + vector) via thesemanticRatioparameter — all server-side, no pool ceiling. Requiresmeilisearch/meilisearch-phpand a boundMeilisearch\Clientin the app container.
// AppServiceProvider::register() $this->app->singleton(\Meilisearch\Client::class, fn () => new \Meilisearch\Client(config('scout.meilisearch.host'), config('scout.meilisearch.key')) );
// .env LARACRATE_CHUNKS_DRIVER=meilisearch LARACRATE_MEILISEARCH_INDEX=laracrate_file_chunks LARACRATE_MEILISEARCH_EMBEDDER=default
Custom store (Qdrant, pgvector, Pinecone, etc.):
// AppServiceProvider::register() $this->app->bind( \EduLazaro\Laracrate\Contracts\ChunkStore::class, \App\Search\MyQdrantChunkStore::class );
The ChunkStore contract has four methods: upsert, delete, search and
countForFile. Pick whichever vector store fits your infra.
ocr, PDF scanning fallback
For PDFs that don't have extractable native text (scanned documents), the
package ships OcrPdfTextExtractor. It sends the PDF base64 to an API and
gets back the extracted text. No Imagick, no Ghostscript, no shell_exec,
no pdftoppm. Just PHP and HTTP.
'ocr' => [ 'provider' => env('LARACRATE_OCR_PROVIDER', 'anthropic'), // 'anthropic' | 'openai' 'anthropic' => [ 'api_key' => env('LARACRATE_ANTHROPIC_API_KEY') ?: env('ANTHROPIC_API_KEY'), 'model' => env('LARACRATE_OCR_ANTHROPIC_MODEL', env('LARACRATE_OCR_MODEL', 'claude-haiku-4-5')), ], 'openai' => [ 'api_key' => env('LARACRATE_OPENAI_API_KEY') ?: env('OPENAI_API_KEY'), 'model' => env('LARACRATE_OCR_OPENAI_MODEL', env('LARACRATE_OCR_MODEL', 'gpt-4o-mini')), ], ],
API key resolution, in order:
- Explicit constructor argument (advanced).
- Provider-specific
LARACRATE_*_API_KEY(so the package has its own key, distinct from other usages in the app). - Fallback to the generic provider env (
ANTHROPIC_API_KEY,OPENAI_API_KEY).
Model resolution, in order:
- Provider-specific
LARACRATE_OCR_{PROVIDER}_MODEL(e.g.LARACRATE_OCR_ANTHROPIC_MODEL). - Generic
LARACRATE_OCR_MODEL(applies to whichever provider is active). - Hardcoded default (
claude-haiku-4-5/gpt-4o-mini).
Recommended chain for legal / scanned documents:
'embeddings' => [ 'extractors' => [ \EduLazaro\Laracrate\Extractors\PdfTextExtractor::class, // 1. smalot, free, instant \EduLazaro\Laracrate\Extractors\OcrPdfTextExtractor::class, // 2. OCR fallback for scanned PDFs \EduLazaro\Laracrate\Extractors\PlainTextExtractor::class, // 3. text/* ], 'min_text_per_file' => 100, // if smalot returns < 100 chars, fall back to OCR ],
Cost reference per 10-page PDF (rough estimates, may change):
| Provider | Model | Cost | Native PDF |
|---|---|---|---|
| Anthropic | claude-haiku-4-5 |
~$0.004 | yes (messages API document source) |
| OpenAI | gpt-4o-mini |
~$0.005 | yes (Responses API input_file) |
watermark, per-variant watermark
The watermark is baked into the binary of specific variants. The original (master) NEVER carries a watermark. Only variants that explicitly opt in.
'watermark' => [ 'image_path' => env('LARACRATE_WATERMARK_IMAGE', null), // PNG to overlay 'size' => 0.40, // 40% of the variant's width 'opacity' => 30, // 0 to 100 'position' => 'center', 'text' => [ 'content' => null, // null, fixed string, or closure(File): ?string 'font_size_ratio' => 0.0195, 'color' => 'rgba(255, 255, 255, 0.60)', 'position' => 'bottom-left', 'padding' => 20, 'font_path' => null, ], ],
Per-variant activation:
'collections' => [ 'identity' => [ 'types' => [ 'image' => [ 'variants' => [ 'thumbnail' => ['width' => 300, 'height' => 300], // no watermark 'display' => ['width' => 1200, 'watermark' => true], // with watermark ], ], ], ], ],
If you change the PNG or tweak sizes, regenerate the variants and the master stays untouched.
ui, default theme for the optional Livewire component
'ui' => [ 'default_theme' => env('LARACRATE_THEME', 'default'), ],
Only relevant if you use the optional Livewire component. Details in its section.
queue
'queue' => [ 'connection' => env('LARACRATE_QUEUE_CONNECTION', null), // null uses Laravel's default 'name' => env('LARACRATE_QUEUE_NAME', 'default'), ],
Useful for isolating file processing from other queues.
Using it from your model
HasFiles trait
use EduLazaro\Laracrate\Concerns\HasFiles; class Property extends Model { use HasFiles; // Per-model override (optional). Recursively merged with the global collection. protected array $fileCollections = [ 'gallery' => [ 'types' => [ 'image' => [ 'variants' => [ 'og' => ['width' => 1200, 'height' => 630, 'fit' => true, 'format' => 'jpg'], ], ], ], ], ]; }
Server-side upload (regular request)
$property->addFile($request->file('image'), 'gallery', [ 'title' => 'Front facade', 'label' => 'facade', ]);
Direct upload to R2 (presigned, recommended)
JS client:
import { presignAndUpload } from 'edulazaro/laracrate/resources/js/laracrate'; const result = await presignAndUpload(fileInput.files[0], { disk: 'media', fileable: { type: 'property', id: 123 }, collection: 'gallery', maxSizeKb: 102400, onProgress: (pct) => console.log(`${(pct * 100).toFixed(0)}%`), }); await fetch(`/properties/${propertyId}/files`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken() }, body: JSON.stringify({ ...result, collection: 'gallery' }), });
Backend confirm:
use EduLazaro\Laracrate\Support\FileUpload; Route::post('/properties/{property}/files', function (Request $request, Property $property) { $upload = FileUpload::fromArray($request->validate([ 'key' => 'required|string', 'mime_type' => 'required|string', 'original_name' => 'required|string', 'size' => 'required|integer', ])); return $property->addFile($upload, $request->input('collection', 'gallery')); });
Multipart (large files, larger than 100 MB)
The JS helper detects the size and switches to multipart automatically when it crosses the threshold. The backend is already covered by the package routes.
Show in blade
{{-- File URL --}} <img src="{{ $property->file('gallery')?->variant('medium')->url() }}"> {{-- Variant with dot notation --}} <img src="{{ $videoFile->variant('preview.thumbnail')->url('image') }}"> {{-- Helpers with automatic placeholder fallback --}} <img src="{{ $user->fileLink('avatar', 'medium') }}"> {{-- Render with a configurable blade component --}} {{ $user->fileRender('avatar', 'medium', ['class' => 'w-12 h-12 rounded-full']) }} {{-- Direct stream link (collections with access=stream) --}} <a href="{{ $file->link }}">Download</a> <img src="{{ $file->preview_link }}">
$file->variant('preview.thumbnail') walks with dot notation and falls back to the closest real ancestor if the chain breaks (it never returns null). If you need to fail loudly, use variantOrFail().
Helpers fileLink() and fileRender()
They remove the null-check boilerplate:
$user->fileLink('avatar') // URL or configured placeholder $user->fileLink('avatar', 'medium') // medium variant $user->fileLink('cover', 'preview.thumbnail') // dot notation $user->fileLink('cover', 'preview.small', 'image') // force a type $user->fileRender('avatar', 'medium', ['class' => 'w-12 h-12']) // produces <x-{component} :model="$user" :url="..." class="w-12 h-12" />
fileLink() returns string|null. fileRender() returns HtmlString.
Default blade component per collection
'collections' => [ 'avatar' => [ 'component' => 'user-avatar', // ... ], ],
Component in your app:
{{-- resources/views/components/user-avatar.blade.php --}} @props(['model', 'url']) @if($url) <img src="{{ $url }}" {{ $attributes->merge(['class' => 'rounded-full']) }} alt="{{ $model->name }}"> @else <div {{ $attributes->merge(['class' => 'rounded-full bg-gray-300 flex items-center justify-center text-white']) }}> {{ strtoupper(mb_substr($model->name, 0, 1)) }} </div> @endif
Delete, reorder, publish
$property->deleteFile($file); $property->reorderFiles('gallery', $request->input('ids')); $file->makeDefault(); $file->publish(); $file->unpublish();
Policies (authorization)
use EduLazaro\Laracrate\Support\PolicyRegistry; // AppServiceProvider::boot() app(PolicyRegistry::class) ->viewable('property', fn ($file, $user) => $user && $file->fileable->isOwnedBy($user)) ->editable('property', fn ($file, $user) => $user && $file->fileable->canEdit($user)) ->deletable('property', fn ($file, $user) => $user && $file->fileable->canEdit($user));
Defaults when no policy is registered:
- The human creator of the File can always view, edit, and delete.
- Files with
access='public'can always be viewed. - Everything else: deny.
Processing pipeline
When a top-level File is created (no parent_id), FileObserver::created dispatches ProcessFileJob (queue). The job orchestrates ProcessFileAction, which iterates the Steps of the ProcessingPipelineRegistry in ascending priority order.
Default steps shipped by the package:
| Priority | Step | Triggers when |
|---|---|---|
| 10 | ExtractImageDimensions |
type === image |
| 10 | ExtractVideoDimensions |
type === video (requires ffprobe) |
| 20 | OptimizeImage |
type === image and collection.optimize_originals === true |
| 25 | TranscodeVideo |
type === video and collection.types.video.transcode === true |
| 40 | GenerateImageVariants |
type === image and there is a variants config |
| 45 | ExtractVideoPreview |
type === video and there is a preview config |
| 45 | ExtractPdfPreview |
type === document and mime === application/pdf |
| 60 | ExtractText |
extract_text or embed, and there is a TextExtractor for the mime |
| 70 | ChunkText |
embed === true and text was extracted |
| 80 | GenerateEmbedding |
embeddings.enabled, embed === true, and there are chunks |
Priority convention:
- 0 to 19: metadata (dimensions, duration).
- 20 to 39: original transformation (optimize, transcode, encrypt).
- 40 to 59: derivatives (variants, previews, thumbnails).
- 60 to 79: semantic extraction (text, OCR, transcription).
- 80 to 99: AI (chunking, embeddings, classification).
Events:
FileProcessingStarted, before the first step.FileProcessed, all steps completed OK.FileProcessingFailed, a step threw.VariantGenerated, when a variant is created.EmbeddingsReady, when embeddings are generated.
Fail-fast policy: if a step throws, the File is left at processing_status = FAILED and ProcessFileJob retries with backoff (3 tries: 10s, 30s, 60s). Subsequent steps do not run on that attempt.
If the File is deleted before the worker reaches the job (typical when setFile() replaces an avatar), Laravel discards the job silently thanks to $deleteWhenMissingModels = true. No zombie entries in failed_jobs.
Extending the pipeline from your app
There are two extension points, depending on whether your step should run for every file in the system or only for a specific collection.
Global step (runs for every file)
Register at boot in the FileActionRegistry. The step's own supports() decides which files it actually touches.
// AppServiceProvider::boot() $registry = app(\EduLazaro\Laracrate\Support\FileActionRegistry::class); // Add your own step $registry->add(new \App\Files\Pipeline\VirusScanStep()); // Remove a default $registry->remove(\EduLazaro\Laracrate\Pipeline\Steps\Image\OptimizeImageStep::class);
Per-collection step (runs only for files in that collection)
Declare it under actions in the collection config — no service-provider wiring needed. Useful for domain-specific work (deadlines detection, document classification) that only makes sense for one collection.
'collections' => [ 'documents' => [ // ... 'actions' => [ \App\Pipeline\Steps\ClassifyDocumentStep::class, ], 'models' => [ // Optional: extra steps that only apply when the fileable // is a specific morph type. Cumulative with the top-level // 'actions' above — both run. 'case' => ['actions' => [\App\Pipeline\Steps\DetectDeadlinesStep::class]], 'lawsuit' => ['actions' => [\App\Pipeline\Steps\AutofillLawsuitStep::class]], ], ], ],
Writing a step
Both registries expect classes implementing FileActionInterface (from edulazaro/laractions). supports() is optional; if absent, handle() runs for every file in scope.
namespace App\Files\Pipeline; use App\Files\Actions\VirusScanAction; use EduLazaro\Laracrate\Contracts\FileActionInterface; use EduLazaro\Laracrate\Models\File; use EduLazaro\Laractions\Action; class VirusScanStep extends Action implements FileActionInterface { public function supports(File $file): bool { return $file->creator_type === 'user' && in_array($file->collection, ['documents', 'attachments'], true); } public function priority(): int { return 5; } public function handle(File $file): void { VirusScanAction::create()->run(['file' => $file]); } }
The global registry and the collection's actions array are merged, deduplicated (by class), and sorted by priority() ascending before each pipeline run. Use the priority bands documented above (0-19 metadata, 20-39 transforms, 40-59 derivatives, 60-79 semantic, 80-99 AI) to slot your step where it makes sense.
Variants
Variants are child rows of laracrate_files with parent_id and variant. The cascade FK deletes them when you delete the parent. The FileObserver deletes the binary in R2 when the row is force-deleted.
Path convention
pathof the original:{fileable_type}/{fileable_id}/{collection}/{ulid_filename.ext}.pathof a variant:{parentDir}/variants/{baseName}_{variantName}.{ext}.pathof a sibling (for example, a transcodedmp4replacing themov):{parentDir}/{newName}.{ext}.
Helpers on the File model:
$file->key // ltrim($file->path, '/'), the full key $file->variantKey($newName) // build the key for a variant (variants/ subdir) $file->siblingKey($newName) // build the key for a sibling (same dir) $file->createVariant($name, $overrides) // create a variant row inheriting parent scope
Per-variant watermark
The watermark is baked into the variant's binary at generation time. The original always stays clean. If you change the PNG or text tomorrow, regenerate the variants and you are done.
See the watermark config block.
Upload modes
| Mode | When to use | Pros | Cons |
|---|---|---|---|
Via server (addFile($uploadedFile)) |
small files, strict server-side validation | encrypt at rest possible, PHP validation | binary flows through PHP |
| Direct presigned (PUT to R2) | the normal flow | no PHP in the upload path, scales well | no encrypt at rest |
| Multipart (larger than 100 MB) | large videos, datasets | parallelizable parts, resumable | more client complexity |
The presign accepts fileable_type, fileable_id, and collection to generate the canonical key directly. If the model is unknown at upload time, the binary lands in temp/ and CreateFileAction moves it with S3 server-side copyObject (zero download to PHP).
HTTP endpoints
| Method | Route | Description |
|---|---|---|
| POST | /laracrate/uploads/presign |
Generate a presigned URL (single PUT) |
| DELETE | /laracrate/uploads/{disk}/{encodedKey} |
Cancel a temp/ upload |
| POST | /laracrate/multipart/init |
Start a multipart upload |
| POST | /laracrate/multipart/{id}/parts |
Re-issue presigned URLs for parts |
| POST | /laracrate/multipart/{id}/complete |
Assemble parts and register the File |
| DELETE | /laracrate/multipart/{id} |
Abort a multipart session |
| GET | /files/{slug}/stream |
Stream with audit (collections access=stream) |
| GET | /files/{slug}/preview |
Stream without bumping last_downloaded_at |
| GET | /files/{slug}/download |
Force download (Content-Disposition: attachment) |
| GET | /laracrate/files/{slug}/status |
File status for polling after async upload |
| POST | /laracrate/files/status |
Batch status of multiple slugs |
| POST | /_laracrate/local/upload |
Local-driver upload (Laravel signed route) |
| GET | /_laracrate/local/serve/{slug} |
Serve a File from the local disk |
Sensitive content
For collections with access=stream, the per-request flow is:
- Package URL signed by Laravel (TTL
route_signed_ttl). - The controller validates the signature.
- If
sensitive=true, validatesAuth::id() === query('u')(URL bind). - Policy chain,
FilePolicy::view($file, $user)readsPolicyRegistry. - If
is_encrypted=true,DecryptFileActiondecrypts before serving. - Audit, increments
last_downloaded_at, optionally logs IP and user_id.
The watermark is not applied here, it is baked into the variant.
Artisan commands
# Abort stale multipart sessions (schedule hourly) php artisan laracrate:abort-stale-multipart # Delete Files past their TTL together with their binaries (schedule hourly) php artisan laracrate:purge-expired
Recommended schedule in app/Console/Kernel.php:
$schedule->command('laracrate:abort-stale-multipart')->hourly(); $schedule->command('laracrate:purge-expired')->hourly();
Optional Livewire components
The package ships six Livewire components that cover the common upload UIs. All are fully optional: the core works without Livewire, and your app can build its own uploader or call addFile()/setFile() directly from your forms.
| Component | Use case |
|---|---|
LaracrateUploader |
Card-style uploader for a single collection (avatar, cover, single doc). |
LaracrateDropzone |
Multi-file dropzone with progress bars and previews. |
LaracrateDropzoneSingle |
Single-file dropzone variant. |
LaracrateUploaderDeferred |
Same as LaracrateUploader but renders only after the parent dispatches open. |
LaracrateDropzoneDeferred |
Deferred-mount variant of LaracrateDropzone. |
LaracrateDropzoneSingleDeferred |
Deferred-mount variant of LaracrateDropzoneSingle. |
Deferred variants are useful inside modals or tabs where you don't want JS/CSS to load until the user opens the panel.
{{-- Single-collection card --}} <livewire:laracrate-uploader :model="$user" collection="avatar" /> <livewire:laracrate-uploader :model="$user" collection="avatar" theme="ios" layout="portrait" /> {{-- Multi-file dropzone --}} <livewire:laracrate-dropzone :model="$case" collection="documents" /> {{-- Deferred — only mounts when the parent dispatches 'open-laracrate' --}} <livewire:laracrate-dropzone-deferred :model="$case" collection="documents" name="case-docs" />
All components support 8 themes (default, brutalist, material, ios, glassmorphism, neon, minimal, neumorphism) and 2 layouts for the card uploader (row, portrait). The global theme is configured at config('laracrate.ui.default_theme').
Publish the views to customize:
php artisan vendor:publish --tag=laracrate-views
If you do not use Livewire, ignore this section. Themes and the components are not loaded unless rendered.
Full API
HasFiles trait
$model->files(?$collection = null) // MorphMany (top-level only) $model->file($collection) // first file ordered by default → latest $model->images($collection) // shortcut of files($collection)->where(type, image) $model->getFile($collection) // first file (alias) $model->defaultFile($collection) // file with default=true $model->addFile($upload, $collection, $metadata = []) $model->setFile($collection, $upload, $metadata = []) // single, replaces the existing $model->deleteFile($file, $forceDelete = false) $model->reorderFiles($collection, $orderedIds) $model->setDefaultFile($file) $model->fileLink($collection, $variant = null, $forceType = null): ?string $model->fileRender($collection, $variant = null, $attrs = []): HtmlString $model->getCollectionConfig($collection): array $model->getDiskFor($collection): string $model->resolveFileTenant(): ?Model
File model
// Relations $file->parent $file->children $file->fileable $file->creator $file->tenant $file->contents // chunks from laracrate_file_contents // Variants $file->variant('preview.thumbnail') // dot notation, falls back to ancestor $file->variantOrFail('preview.thumbnail') // throws if the chain breaks // URLs $file->url($forceType = null) // real URL or placeholder $file->link // accessor: alias of url() $file->preview_link // accessor: variant('preview.thumbnail')->url('image') $file->streamUrl() $file->downloadUrl() $file->placeholderFor('image') // Storage $file->key // accessor: ltrim(path, '/') $file->variantKey($newName) $file->siblingKey($newName) $file->createVariant($variantName, $overrides) // State $file->makeDefault() $file->publish() / unpublish() $file->isVariant() / isTopLevel() / isSensitive() $file->isImage() / isVideo() / isAudio() / isDocument() $file->createdByUser() / createdByAgent() / createdAutomatically() // Extracted text (when embed) $file->extractedText(): ?string // joins all chunks $file->hasEmbeddings(): bool // Authorization (delegates to PolicyRegistry) $file->canView($user) $file->canEdit($user) $file->canDelete($user) // Scopes File::published() File::unpublished() File::default() File::ordered() File::topLevel() File::withDescendants(2) File::forTenant($tenant)
StorageManager service
$manager = app(\EduLazaro\Laracrate\Services\StorageManager::class); $manager->urlFor($file) // delegates to GeneratePublicUrl/Signed/Stream $manager->diskFor($file) // Storage::disk for the File $manager->readBinary($file) // full binary contents $manager->writeBinary($disk, $key, $content, $mime) $manager->deleteFromBackend($disk, $key) $manager->moveServerSide($disk, $fromKey, $toKey) // S3 copyObject $manager->batchDelete($disk, $keys) // up to 1000 keys per request $manager->presignedUpload($disk, $key, $mime, $maxSize, $minutes = 15) $manager->withLocalCopy($file, $callback) // safe temporary download $manager->getCollectionConfig($collection): array $manager->getTypeConfig($collection, $type): array $manager->acceptsType($collection, $type): bool $manager->driverOf($disk): string $manager->s3ClientOf($disk): ?S3Client
UsageReporter service
Aggregates storage consumption across tenants, creators, or collections. Useful for billing, quotas, or dashboard widgets without writing custom SUM queries.
$usage = app(\EduLazaro\Laracrate\Services\UsageReporter::class); $stats = $usage->forTenant($organization); // total bytes used by an org/tenant $stats = $usage->forCreator($user); // total bytes uploaded by a user $stats = $usage->forCollection('documents'); // total bytes in one collection
Each call returns a UsageStats value object:
$stats->bytes // raw byte count (int) $stats->files // number of files (int) $stats->byCollection // ['avatar' => 12345, 'documents' => 9876543, ...] $stats->human() // "1.42 GB" $stats->exceeds($limit) // bool, useful for quota checks
forTenant honors the polymorphic tenant_* columns and includes variants of those files. forCollection is global across tenants.
Tests
Storage::fake() and SQLite in-memory, no Docker, no external services:
cd packages/edulazaro/laracrate
composer install
vendor/bin/phpunit
Tests cover the model, trait, observer, manager, policies, presigned controller, stream controller, multipart, and embeddings.
Dependencies
- Laravel 11+ and PHP 8.2+
intervention/image(image processing, watermark)aws/aws-sdk-php(S3/R2 presign and multipart)edulazaro/laractions(base Action class)smalot/pdfparser(optional, forPdfTextExtractor)imagickPHP extension (recommended) orgdffmpeg,ffprobeon PATH (only if you use video)
License
MIT