edulazaro/laracrate
Composer 安装命令:
composer require 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.
README 文档
README
Laracrate
Polymorphic file storage for Laravel: direct uploads to R2/S3, granular access control, sensitive-content streaming, automatic image variants, video and PDF previews, per-variant watermarks, multipart uploads, text extraction, vector embeddings, and hybrid (keyword plus semantic) search.
Table of contents
- What is Laracrate?
- Philosophy
- Requirements
- Installation
- Quick start
- Core concepts
- Data model
- Configuration
- Working with files from your models
- Displaying files
- Upload modes
- HTTP endpoints
- Folders
- File slots
- Processing pipeline
- Images, variants and watermarks
- Video and PDF previews
- Text extraction, embeddings and search (RAG)
- Access control and authorization
- Sensitive content and encryption
- Multi-tenancy, buckets and usage
- Livewire components and themes
- Localization
- Artisan commands
- Events
- API reference
- Testing
- Roadmap
- Sponsors
- Author
- License
What is Laracrate?
Laracrate is a polymorphic file storage layer for Laravel built for object storage (Cloudflare R2, AWS S3) and local disks. It lets any Eloquent model own collections of files, uploads the binary straight from the browser to your bucket (the file never passes through PHP), then runs an asynchronous pipeline that derives everything else: image variants, video and PDF previews, extracted text, vector embeddings, and a searchable chunk store for retrieval-augmented generation. The backend is fully decoupled from any frontend, and an optional set of Livewire upload components ships in the box if you want them.
What you get:
- Direct upload to R2 and S3. Presigned single-part PUT for normal files, and multipart upload for large files (parallel parts with per-part retries and ETag assembly).
- Polymorphic ownership. Three independent morphs per file (
fileable,creator/owner,tenant) so a file knows what it belongs to, who created it, and which tenant scopes it. - Asynchronous processing pipeline. Creating a file dispatches a queued job that runs ordered steps by file type. Your user's upload stays instant.
- Image variants and per-variant watermarks. Automatic resized derivatives with optional watermarking configured per variant.
- Video and PDF previews. Transcoding, dimension extraction, and rasterized preview frames.
- Three access modes per collection.
public(direct CDN),signed(temporary signed URL), andstream(controller with audit and viewer binding). - Text extraction, embeddings and hybrid search. Opt-in extraction, chunking, embedding generation, and a pluggable
ChunkStore(MySQL or Meilisearch) for keyword plus semantic search. - Folders and slots. A folder tree for organizing files, and file slots for "fill in this required document" workflows with quotas.
- Multi-tenant buckets and usage accounting. Optional dedicated bucket per tenant and storage usage reporting for quotas.
- Optional Livewire UI. Six upload components across eleven visual themes, all publishable. None of it is required to use the package.
At a glance:
Your model (HasFiles)
| addFile($upload, $collection, ...)
v
CreateFileAction ----- writes binary -----> Storage backend (R2 / S3 / local)
| (StorageManager: plain disk or tenant bucket)
v
File row (laracrate_files) processing_status = pending
| FileObserver::created() -> dispatch
v
ProcessFileJob (queue) -> ProcessFileAction (orchestrator)
| resolves steps by file type, collection and model, ordered by priority
v
Pipeline steps:
image : extract dimensions -> optimize -> generate variants
video : extract dimensions -> transcode -> extract preview
pdf : extract preview
text : extract text -> chunk -> generate embeddings -> persist chunks
| |
v v
variants (child File rows) ChunkStore.store()
(MysqlChunkStore | MeilisearchChunkStore)
|
processing_status = completed v
search() (keyword + semantic)
Philosophy
Laracrate follows six principles. Keep them in mind and the rest of the API will feel predictable.
- The backend is frontend-agnostic. There is zero coupling to Livewire, Vue, or Alpine in the storage layer. The Livewire uploader is an optional, publishable convenience, not a dependency of the core.
- Reuse
Storage::disk(). Disk credentials live in your app'sconfig/filesystems.php, not here. Laracrate resolves disks through Laravel's filesystem and never duplicates that configuration. - Everything is a pipeline of Actions. Each operation is an isolated, testable, queueable class (built on
edulazaro/laractions). The processing pipeline is just a registry of ordered steps you can extend. - Processing is asynchronous. Variants, video and PDF previews, text extraction, and embeddings all run on the queue. The user's upload is instantaneous, and a file simply stays
pendinguntil a worker picks it up. - Access is decided per collection. Each collection declares one of three access modes (
public,signed,stream), so the right URL strategy and audit behavior come from configuration, not scattered checks. pathis the full object key. A file'spathstores the complete key of the object in the disk (directories, filename, extension included). Use thekeyaccessor to read it. Never rebuild a key by concatenatingpathandname.
Requirements
Laracrate targets current Laravel. The core requirements below are enough to store files and serve them. Media processing, text extraction, and search rely on optional system tools and services that you only install for the features you use.
| What | Requirement | Notes |
|---|---|---|
| PHP | >= 8.2 |
|
| Laravel | laravel/framework >= 12.0 |
|
| Actions | edulazaro/laractions >= 1.0 |
Queueable, testable action classes used throughout. |
| AWS SDK | aws/aws-sdk-php ^3.300 |
Presigned uploads and S3/R2 operations. |
| Image processing | intervention/image ^3.0 |
Image variants and optimization (needs an imagick or gd PHP extension). |
| Flysystem adapter | league/flysystem-aws-s3-v3 ^3.0 |
S3/R2 disk driver. |
The four packages above are hard Composer dependencies and are installed for you. The following are optional and unlock specific pipeline features:
| Optional dependency | Unlocks |
|---|---|
smalot/pdfparser |
Native PDF text extraction (PdfTextExtractor). |
imagick or gd PHP extension |
Image variant generation through Intervention Image. |
ffmpeg + ffprobe |
Video dimension extraction, transcoding, and preview frames. |
poppler-utils (pdftoppm) or ghostscript |
Rasterized PDF previews. |
meilisearch/meilisearch-php + a Meilisearch server |
The meilisearch chunk store for server-side hybrid search (see the Text extraction, embeddings and search section). |
| An OpenAI or Anthropic API key | Embeddings, plus OCR and transcription based extraction. |
These tools are invoked only inside queued pipeline steps, so they never block an upload. If a tool is missing, the corresponding step is skipped or the file stays unprocessed, but storing and serving the file still works.
Installation
Laracrate installs like any Laravel package. You pull it in with Composer, publish the config, run the migrations, and point your collections at a storage disk. The package ships sensible defaults, so a basic image upload works with almost no setup.
Prerequisites
See the Requirements section for the full list. In short you need PHP 8.2 or higher and Laravel 12 or higher.
Install with Composer
composer require edulazaro/laracrate
The service provider is auto-discovered, so there is nothing to register manually.
Publish the config and run migrations
Publish the config file, then migrate. The migrations create the package tables (all prefixed laracrate_) and are timestamped 0000_00_00 so they run before your own migrations.
php artisan vendor:publish --tag=laracrate-config php artisan migrate
You only need the config tag to get started. The package loads its migrations, views, and translations directly from the package directory, so publishing those is optional and only needed when you want to customize them.
| Tag | What it copies | Destination |
|---|---|---|
laracrate-config |
The config file | config/laracrate.php |
laracrate-migrations |
The migration files | database/migrations |
laracrate-views |
The Blade views (uploader themes, etc.) | resources/views/vendor/laracrate |
laracrate-translations |
The language files | lang/vendor/laracrate |
After publishing config, clear the cache if you have it cached:
php artisan config:clear
Declare your disks
Laracrate never stores storage credentials. It reuses Laravel's Storage::disk(), so every collection points at a disk you define in config/filesystems.php. The default config in config/laracrate.php ships collections that reference two disks, media and documents, so define those (or rename the collections to match disks you already have).
For production on S3 or Cloudflare R2, add an s3 driver disk. R2 is S3-compatible, so the same driver works, you just set R2 credentials and endpoint:
// config/filesystems.php 'disks' => [ // S3 or Cloudflare R2 (R2 uses the same s3 driver) 'media' => [ 'driver' => 's3', 'key' => env('R2_ACCESS_KEY_ID'), 'secret' => env('R2_SECRET_ACCESS_KEY'), 'region' => env('R2_DEFAULT_REGION', 'auto'), 'bucket' => env('R2_BUCKET'), 'endpoint' => env('R2_ENDPOINT'), 'use_path_style_endpoint' => true, 'throw' => true, ], 'documents' => [ 'driver' => 's3', 'key' => env('R2_ACCESS_KEY_ID'), 'secret' => env('R2_SECRET_ACCESS_KEY'), 'region' => env('R2_DEFAULT_REGION', 'auto'), 'bucket' => env('R2_DOCUMENTS_BUCKET'), 'endpoint' => env('R2_ENDPOINT'), 'use_path_style_endpoint' => true, 'throw' => true, ], ],
For local development you can point the same disk names at the local filesystem so you do not need a bucket while building:
// config/filesystems.php (local dev) 'media' => [ 'driver' => 'local', 'root' => storage_path('app/media'), 'url' => env('APP_URL') . '/storage/media', 'visibility' => 'public', 'throw' => true, ],
Recommended: use real object storage in development too. The local driver above is the quickest way to start, but for consistency with production it is strongly recommended to point your development disks at S3-compatible storage instead: either a free Cloudflare R2 bucket, or a local MinIO instance. The direct-upload flow (presigned PUT, multipart, and the server-side copyObject move from temp/ to the canonical key) relies on real S3/R2 semantics that the local driver only emulates through signed-route fallbacks, so building against R2 or MinIO surfaces problems the local driver would otherwise hide. MinIO uses the same s3 driver shown above: point endpoint at your MinIO URL (for example http://localhost:9000) and keep use_path_style_endpoint => true.
Each collection declares its own disk in config/laracrate.php. The package raises an error on purpose if a collection has no disk, so there is no silent default. See the Configuration section for the full collection schema and the Multi-tenancy, buckets and usage section for per-tenant bucket overrides.
Quick start
This is the fastest path from a fresh install to a stored, displayed file. The example uses the built-in avatar collection from the published config, which points at the media disk, accepts images, and is marked single (one file per model).
1. Add the trait to a model
Add HasFiles to any Eloquent model that should own files.
use EduLazaro\Laracrate\Concerns\HasFiles; class User extends Authenticatable { use HasFiles; }
2. Point a collection at a disk
The avatar collection already exists in config/laracrate.php:
// config/laracrate.php 'collections' => [ 'avatar' => [ 'disk' => 'media', 'access' => 'public', 'single' => true, 'types' => [ 'image' => [ 'variants' => [ 'small' => ['width' => 64, 'height' => 64, 'fit' => true], 'medium' => ['width' => 128, 'height' => 128, 'fit' => true], 'large' => ['width' => 256, 'height' => 256, 'fit' => true], ], ], ], ], ],
Make sure the media disk exists in config/filesystems.php (see the Installation section).
3. Upload a file from a controller
Call addFile() with the uploaded file and the collection name. Because avatar is single, use setFile() instead if you want each new upload to replace the previous one.
use Illuminate\Http\Request; class AvatarController { public function store(Request $request) { $request->validate(['avatar' => 'required|image']); $file = $request->user()->setFile('avatar', $request->file('avatar')); return back(); } }
addFile() accepts an UploadedFile, a local path string, a FileUpload, or a Binary. It returns the created File model (or null). The full signature and the optional $data, $slots, $creator, $owner, and $folder arguments are documented in the Working with files from your models section.
4. Display the file in Blade
Use fileLink() to get a URL, falling back to a configured placeholder when no file exists. Pass a variant name as the second argument to get a resized version.
{{-- Original (or placeholder if none) --}} <img src="{{ $user->fileLink('avatar') }}" alt="Avatar"> {{-- The 'medium' variant --}} <img src="{{ $user->fileLink('avatar', 'medium') }}" alt="Avatar">
Since avatar declares a single type (image), you do not need to pass the type argument. For multi-type collections you do. See the Displaying files section for fileRender() and placeholder resolution.
5. Run a queue worker
Originals are stored instantly, but variants and previews are generated asynchronously on the queue by ProcessFileJob. Until a worker processes the job, fileLink('avatar', 'medium') may fall back to the original or a placeholder. Run a worker so processing completes:
php artisan queue:work
If the queue is not running, the file stays in a pending processing state until a worker starts. That is by design, not a bug. See the Processing pipeline section for how steps are ordered and run.
Core concepts
Before you reach for the reference sections, here is the vocabulary Laracrate uses. Every file is a row in laracrate_files (an Eloquent EduLazaro\Laracrate\Models\File) decorated with the concepts below. Read this once and the rest of the docs will click.
Collection. The business grouping a file belongs to, stored in the collection column (for example avatar, documents, lawsuit-document). A collection is declared in config('laracrate.collections.*') and decides the disk, the access mode, the accepted types, the variants to generate, and whether text extraction or embeddings run. When you attach a file to one of your models you always name its collection (see the Working with files from your models section).
Type. A coarse media class stored in the type column and cast to the EduLazaro\Laracrate\Enums\FileType enum: IMAGE, VIDEO, AUDIO, or DOCUMENT. Laracrate derives it from the MIME with FileType::fromMime() (anything that is not image/*, video/*, or audio/* becomes DOCUMENT). The pipeline picks which steps run per type.
File vs variant. A top-level file has parent_id = null. A variant is a derived file (a thumbnail, an optimized copy, a video or PDF preview) linked to its parent by parent_id and named by the variant column. Variants are real File rows, created with $file->createVariant($name, $overrides), and they inherit the parent scope (fileable, creator, tenant, disk, collection, access). You navigate them with dot notation: $file->variant('preview.thumbnail') walks the tree and falls back to the nearest existing ancestor instead of returning null, while $file->variantOrFail('preview.thumbnail') throws if any link is missing. Helpers: $file->isTopLevel(), $file->isVariant(). See the Images, variants and watermarks section.
The four polymorphic morphs. A file carries four independent morphTo relations. They are orthogonal, not a hierarchy, and you set only the ones your app needs.
| Morph | Columns | Meaning |
|---|---|---|
fileable |
fileable_type, fileable_id |
What the file belongs to (a Property, User, Service). |
creator |
creator_type, creator_id |
Who created it. Null for system-generated files. |
owner |
owner_type, owner_id |
The semantic owner when it differs from the creator (uploaded on behalf of someone). Null when it matches the creator. |
tenant |
tenant_type, tenant_id |
The multi-tenant scope. Leave null in single-tenant apps. |
Use $file->effectiveOwner() to get the explicit owner or fall back to the creator. See the Multi-tenancy, buckets and usage section.
path is the full object key. The path column stores the complete object key on the disk (directories, filename, and extension together), for example user/1/avatar/01J...webp. It is not just the directory, and you never concatenate it with name to build a key. Always read the key through the accessor $file->key, which trims a stray leading slash. Derived keys come from $file->siblingKey($name) (same directory) and $file->variantKey($name) (sibling variants/ subdirectory). The name column is just basename($path).
Access mode. The access column, cast to EduLazaro\Laracrate\Enums\FileAccess, decides how a file is served:
| Case | How it is served |
|---|---|
PUBLIC |
Direct CDN URL via Storage::url(). No signature, no audit. |
SIGNED |
Temporary signed URL via Storage::temporaryUrl(), cached server-side. |
STREAM |
Served through the package controller: per-request authorization, audit, optional viewer binding, encryption, and watermark. |
Each collection declares its access mode. See the Upload modes and Access control and authorization sections.
Visibility. A separate axis from access, stored in the visibility column and cast to EduLazaro\Laracrate\Enums\FileVisibility: OWNER, GROUP, TENANT, or WORLD. Where access governs the transport, visibility expresses the intended audience for your policies to enforce.
Processing status. The lifecycle of the async pipeline, in the processing_status column and cast to EduLazaro\Laracrate\Enums\ProcessingStatus: PENDING (just created, queued) goes to PROCESSING (steps running) and ends in either COMPLETED (all applicable steps ran) or FAILED (a step threw, with the message in processing_error). The enum offers isTerminal() and isInProgress(). This applies only to top-level files; variants are born COMPLETED. See the Processing pipeline section.
Chunk. A piece of extracted text, modeled by EduLazaro\Laracrate\Models\FileChunk and accessed via $file->chunks() (ordered by chunk_index) or $file->chunk() (the first chunk). Each chunk carries chunk_index, text, tokens, metadata, and an optional context. The lightweight chunk row lives in laracrate_file_chunks; the heavy payload (text plus embedding) lives in laracrate_file_chunk_data. Chunks are the unit of RAG search. See the Text extraction, embeddings and search section.
ChunkStore. The contract (EduLazaro\Laracrate\Contracts\ChunkStore) that abstracts where chunks are persisted and searched. Its methods are store(File $file, array $chunks): int, getByFile(File $file): Collection, search(string $query, array $filters = [], array $options = []): Collection, deleteByFile(File $file): void, and driverName(): string. The shipped drivers are MysqlChunkStore (LIKE plus cosine similarity in PHP) and MeilisearchChunkStore (native hybrid BM25 plus vector). Bind your own implementation (Qdrant, pgvector) to swap backends.
Embedding. A numeric vector that captures the meaning of a chunk (1536 dimensions for text-embedding-3-small), produced by an EmbeddingProvider and used for semantic similarity. Use $file->hasEmbeddings() to check whether every chunk has one.
semantic_ratio. The 0-1 weight of semantic ranking versus keyword ranking in ChunkStore::search(), passed in the options array (default 0.7). A value of 0 is keyword only and embeds nothing (no embedding API cost), 1 is fully semantic, and anything above 0 embeds the query.
Slot. A named placeholder a user fills in (for example "ID front", "ID back"), modeled by EduLazaro\Laracrate\Models\FileSlot with extension and type restrictions and quotas. Files attach to slots through the laracrate_file_slot_pivot table, reachable via $file->slots(). See the File slots section.
Folder. An optional logical grouping under a fileable, modeled by EduLazaro\Laracrate\Models\Folder and referenced by the folder_id column (null means the fileable root). Move a file with $file->moveToFolder($folder); the binary key on the backend never changes, only folder_id. See the Folders section.
Multipart. The protocol for large uploads (at or above the configured multipart.threshold, default 100 MB). The binary is split into parts of at least 5 MB, uploaded in parallel with per-part retries and reassembled by ETags. The session is tracked in laracrate_multipart_uploads. See the Upload modes section.
Presigned URL. A cryptographically signed, time-limited URL that authorizes a direct operation against the storage backend (PUT to upload, GET to download) without exposing your credentials. The browser uploads straight to R2 or S3 and your application server never touches the bytes. See the Upload modes and HTTP endpoints sections.
Data model
Laracrate ships eight tables, all prefixed with laracrate_. The prefix avoids collisions with the legacy files table that already exists in many Laravel apps. The class names do not repeat the prefix (File, not LaracrateFile), because the EduLazaro\Laracrate\Models namespace already disambiguates. This follows the Cashier and Media Library convention.
The schema started with three tables and grew. Two renames matter if you are upgrading: laracrate_file_contents was renamed to laracrate_file_chunks, and an intermediate laracrate_file_chunk_data table existed for a few migrations before being folded back into laracrate_file_chunks (its text and embedding columns now live directly on the chunk row). The migrations are idempotent, so a fresh install lands on the final shape below.
Tables
| Table | Model | Purpose |
|---|---|---|
laracrate_files |
File |
One row per file (and per variant). The central table. |
laracrate_file_chunks |
FileChunk |
Extracted text split into chunks, with embeddings for search. One row per chunk. |
laracrate_multipart_uploads |
MultipartUpload |
Active and historical S3/R2 multipart upload sessions. |
laracrate_file_slots |
FileSlot |
Named upload slots with per-slot rules (allowed types, count limits). |
laracrate_file_slot_pivot |
(pivot, via File::slots()) |
Many-to-many link between files and slots. |
laracrate_tenant_buckets |
TenantBucket |
Per-tenant dedicated bucket overrides for a base disk. |
laracrate_folders |
Folder |
Folder tree (parent/child plus denormalized path) for organizing files. |
laracrate_folderables |
Folderable |
Aggregated storage usage counter per (owner, collection). |
The laracrate_files.folder_id column links a file to a folder. There is no separate file/folder pivot: a file belongs to at most one folder.
laracrate_files
The File model (EduLazaro\Laracrate\Models\File) wraps this table. Its route key is slug. It uses soft deletes. Columns, grouped by concern:
Identity and hierarchy
| Column | Type | Notes |
|---|---|---|
id |
bigint | Primary key. |
slug |
ulid | Unique. Used as the public route key (never expose id). |
parent_id |
bigint, nullable | Points at the parent file. Null means top-level. Set means this row is a variant. Cascades on delete. |
variant |
string(50), nullable | Variant name (thumbnail, preview, small, ...). Unique together with parent_id. |
folder_id |
bigint, nullable | Folder this file lives in. Null means the root of its fileable. Nulls on folder delete. |
The four morphs
Each file carries four independent polymorphic relations (see the Core concepts section for why they stay orthogonal):
| Columns | Relation | Meaning |
|---|---|---|
fileable_type, fileable_id |
fileable() |
What the file belongs to (a User, Property, Service). |
creator_type, creator_id |
creator() |
Who or what created the row. Null for system-generated. |
owner_type, owner_id |
owner() |
Semantic owner when it differs from the creator. Falls back to the creator via effectiveOwner(). |
tenant_type, tenant_id |
tenant() |
Multi-tenant scope (Organization, Workspace). Null for single-tenant apps. |
Storage key
| Column | Type | Notes |
|---|---|---|
disk |
string | The Laravel disk name, or a tb:{id} token for a dedicated tenant bucket. |
path |
string | The full object key in the disk (directories, filename, extension). Read it through $file->key, never by hand. |
name |
string | basename($path) denormalized. |
original_name |
string | The filename as uploaded. |
extension |
string(10) | Lowercase extension. |
mime_type |
string(100) | Detected MIME type. |
size |
unsignedBigInteger | Bytes. |
digest |
string(80), nullable | Content hash for dedupe or integrity. |
Classification
| Column | Type | Notes |
|---|---|---|
context |
string | Defaults to laracrate.default_context. Indexed. |
collection |
string | Defaults to laracrate.default_collection. Indexed. |
type |
enum | image, video, audio, document. Cast to FileType. Indexed. |
category |
string, nullable | Free-form app category. Indexed. |
Access flags
| Column | Type | Notes |
|---|---|---|
access |
enum | public, signed, stream. Defaults to signed. Cast to FileAccess. Indexed. |
visibility |
string, nullable | Free-form visibility label. The FileVisibility enum (owner, group, tenant, world) is available if you want to use its values. Indexed. |
sensitive |
boolean | Defaults to false. Indexed. |
is_encrypted |
boolean | Defaults to false. |
Metadata and presentation
| Column | Type | Notes |
|---|---|---|
title |
string, nullable | Display title. |
description |
text, nullable | Display description. |
label |
string(100), nullable | Short label. |
default |
boolean | Marks the default file in its (fileable + collection) group. |
position |
unsignedInteger | Sort order. Defaults to 0. |
published |
boolean | Defaults to true. Indexed. |
is_verified |
boolean | Defaults to false. Indexed. |
metadata |
json, nullable | Free-form bag. Cast to array. |
Media metadata
| Column | Type | Notes |
|---|---|---|
duration |
unsignedInteger, nullable | Seconds, for video and audio. |
width, height |
unsignedInteger, nullable | Pixels, for image and video. |
bitrate |
unsignedInteger, nullable | For video and audio. |
sample_rate |
unsignedInteger, nullable | For audio. |
Processing and audit
| Column | Type | Notes |
|---|---|---|
processing_status |
enum, nullable | pending, processing, completed, failed. Cast to ProcessingStatus. |
processing_error |
text, nullable | Error message when a step throws. |
processing_started_at |
timestamp, nullable | When the pipeline began. |
processing_extractor |
string(255), nullable | Class of the text extractor used. |
processing_provider |
string(50), nullable | Embedding or extraction provider. |
processing_model |
string(100), nullable | Model used for embedding or extraction. |
summary |
text, nullable | Optional LLM-distilled summary of the extracted content. |
downloads_count |
unsignedInteger | Defaults to 0. |
last_downloaded_at |
timestamp, nullable | Updated on each served download. |
Indexing trackers
These three timestamps record where a file's chunks have been indexed, so you can re-index incrementally and migrate between search backends safely (see the Text extraction, embeddings and search section).
| Column | Type | Notes |
|---|---|---|
mysql_indexed_at |
timestamp, nullable | Chunks ready in MySQL (LIKE keyword plus cosine in PHP). Indexed. |
meili_indexed_at |
timestamp, nullable | Chunks pushed to Meilisearch. Indexed. |
storage_indexed_at |
timestamp, nullable | {path}.chunks.jsonl backup written. |
Timestamps: created_at, updated_at, plus deleted_at (soft deletes).
laracrate_file_chunks
Wrapped by FileChunk. One row per chunk of extracted text. A collection with no chunking stores everything in a single row at chunk_index 0. The foreign key to laracrate_files cascades on delete.
| Column | Type | Notes |
|---|---|---|
id |
bigint | Primary key. |
file_id |
bigint | Parent file. Cascades on delete. |
chunk_index |
unsignedInteger | Position within the file. Defaults to 0. Unique together with file_id. |
context |
string(30), nullable | Discriminator for multi-section extractions (text for OCR verbatim, description for a generated visual description, or any opaque label). Indexed with file_id. |
text |
longText, nullable | Chunk text. The column has a FULLTEXT index, though the MySQL chunk store currently keyword-matches with SQL LIKE. |
embedding |
json, nullable | Embedding vector. Cast to array. Cosine similarity is computed in PHP. |
tokens |
unsignedInteger, nullable | Token count for the chunk. |
metadata |
json, nullable | Free-form bag (page numbers, etc). Cast to array. |
File::chunks() returns these ordered by chunk_index, and File::chunk() returns the single chunk_index 0 row. The older contents() and content() relations are kept as deprecated aliases for apps migrating from the previous table name.
laracrate_multipart_uploads
Wrapped by MultipartUpload. One row per multipart upload session against an S3-compatible disk. Small files use a single PUT and never touch this table. Completed and aborted rows are kept as an audit trail rather than deleted (see the Upload modes section).
Key columns: upload_id (unique, the provider's id), disk, key, mime_type, expected_size, part_size, total_parts, and status (enum active, completed, aborted, expired, cast to MultipartUploadStatus). It mirrors the file morphs with creator_*, tenant_*, and fileable_* columns plus collection, and links to the resulting row via file_id (nulls on file delete). Lifecycle timestamps: expires_at, completed_at, aborted_at, plus an error column.
laracrate_file_slots and laracrate_file_slot_pivot
FileSlot wraps laracrate_file_slots: named upload slots with rules (see the File slots section). Columns: tenant_type/tenant_id and context_type/context_id for scoping, name, description, color, allowed_extensions (json array), allowed_types (json array of FileType values), max_files_per_creator, max_files_total, and position.
laracrate_file_slot_pivot is the many-to-many link, with file_id and file_slot_id (both cascade on delete, unique together). It has no dedicated model: reach it through File::slots() or FileSlot::files().
laracrate_tenant_buckets
TenantBucket wraps this table: one row overrides a single config disk (the base_disk) with a dedicated bucket for one tenant (see the Multi-tenancy, buckets and usage section). Columns: tenant_type/tenant_id, base_disk, bucket, public_url (nullable), credentials (longText, cast to encrypted:array for bring-your-own-account setups), is_active, and label. Unique on (tenant_type, tenant_id, base_disk). toDiskConfig() merges the base disk config with the override.
laracrate_folders
Folder wraps this table: a parent/child folder tree with a denormalized path kept in sync by an observer (see the Folders section). It uses soft deletes. Columns: a folderable_* morph (the tree owner), parent_id (cascades on delete, null means root), name, path (string(500)), a creator_* morph, and a metadata json bag. Unique on (folderable_type, folderable_id, path).
laracrate_folderables
Despite the name, this is not a pivot. Folderable wraps an aggregated usage counter, one row per (folderable_type, folderable_id, collection), maintained in real time by the file observer when a collection has track_usage enabled. Columns: the folderable_* morph, collection, total_size_bytes, files_count, folders_count, and last_recomputed_at. Unique on (folderable_type, folderable_id, collection). The laracrate:recompute-usage command rebuilds it if you suspect drift.
Configuration
Laracrate is configured entirely through one published file, config/laracrate.php. Publish it during installation (see the Installation section), then shape it to your app. The defaults are safe and runnable as-is: every key below has a working default, so you only override what your app actually needs.
This section walks the file top to bottom. For the deeper behavior that some keys drive (the pipeline, embeddings, watermarks), the relevant H2 section is cross-referenced rather than re-explained here.
php artisan vendor:publish --tag=laracrate-config
Default collection and context
Applied to the schema when a File row is inserted without an explicit collection or context. This happens, for example, when a variant is created and inherits from its parent. Any string is valid; the convention is default. Changing these values updates the column DEFAULT only if you re-run the migration.
'default_collection' => 'default', 'default_context' => 'default',
Defaults per file type
The defaults block declares the safe-by-default allowlist of MIME types, extensions, max sizes, and processing options for each of the four file types (image, document, audio, video). Any collection that does not declare its own accepted_mime_types or accepted_extensions inherits these.
Some formats are deliberately excluded from the allowlist and must be opted into explicitly per collection:
- SVG, because it can carry
<script>. - ICO, because of legacy CVEs.
- HTML, JS, PHP, EXE, BAT, SH, because they are executable.
- ZIP, RAR, 7Z, because they are containers with zip-slip and hidden-content vectors.
To allow any of these, override accepted_mime_types and accepted_extensions in the specific collection along with your own validation policy.
| Type | max_file_size (KB) |
Notable defaults |
|---|---|---|
image |
10240 (10 MB) |
format: webp, quality: 90, variant_quality: 85, max_width: 1920, max_height: 1080, plus thumbnail (300x300), medium (800x800), large (1600x1600) variants |
document |
20480 (20 MB) |
PDF, Word, OpenDocument, RTF, plain text, Markdown, Excel, CSV, PowerPoint, EPUB. No variants (documents use rasterized previews) |
audio |
5120 (5 MB) |
MP3, WAV, OGG, M4A, FLAC, AAC, Opus, WebM |
video |
102400 (100 MB) |
MP4, MOV, WebM, M4V, MKV, AVI, OGV |
The image accepted_extensions are jpeg, jpg, png, gif, webp, heic, heif, bmp, tiff. See the Images, variants and watermarks section for how format, quality, and variants drive processing.
Collections
A collection is the upload policy for one business context (avatars, a gallery, identity documents). Each entry declares where files land, who can read them, and how they are processed. Collections are the central concept of the package, so this is the largest block.
'collections' => [ 'avatar' => [ 'disk' => 'media', 'access' => 'public', 'single' => true, 'types' => [ 'image' => [ 'variants' => [ 'small' => ['width' => 64, 'height' => 64, 'fit' => true], 'medium' => ['width' => 128, 'height' => 128, 'fit' => true], 'large' => ['width' => 256, 'height' => 256, 'fit' => true], ], ], ], ], ],
The package ships four example collections (avatar, gallery, documents, identity) so you can see the shape. Replace them with your own.
Anatomy of a collection entry
| Key | Type | Purpose |
|---|---|---|
disk |
string | The Storage::disk() name from your config/filesystems.php. The package never duplicates credentials; it resolves this disk. There is no default, a missing disk is intentionally an error. |
access |
string | public (CDN-direct), signed (temporary signed URL), or stream (controller with audit and viewer bind). See the Upload modes and Access control sections. |
single |
bool | true keeps one file per owner. Replacing the file removes the previous one. |
sensitive |
bool | Binds access to the authenticated viewer and re-validates on each request. See the Sensitive content section. |
encrypt |
bool | Encrypts the binary before it is stored on the backend. See the Sensitive content and encryption section. |
ttl_hours |
int | Files in the collection expire after this many hours. The laracrate:purge-expired command (run hourly) deletes them, cascading to variants and the backend binary. Useful for temp_uploads, expiring exports, or unpromoted drafts. |
quota_bytes |
int | Storage limit your app can check with UsageReporter before accepting more uploads. The package does not enforce quotas itself. See the Multi-tenancy, buckets and usage section. |
track_usage |
bool | Maintain live per-owner usage counters (file count and total bytes) for this collection in the laracrate_folderables table, updated by the file observer on create and delete, and rebuildable with laracrate:recompute-usage. See the Multi-tenancy, buckets and usage section. |
component |
string | Blade component used for default rendering. |
placeholder |
string | Fallback asset when the file does not exist (highest priority in placeholder resolution, below). |
types |
array | Per-type processing config (image, document, audio, video), keyed by type. Each type block can carry accepted_mime_types, accepted_extensions, max_file_size, variants, preview, and so on, overriding the global defaults. |
variants |
array | Image variant definitions: ['name' => ['width' => ..., 'height' => ..., 'fit' => bool, 'watermark' => bool]]. See the Images, variants and watermarks section. |
preview |
array | Rasterized preview config for documents and videos. Documents accept page, width, engine, and nested variants. Videos accept frame_at and nested variants. See the Video and PDF previews section. |
extract / extract_text |
bool or array | Whether to extract text from the file (extract_text is a legacy alias for extract). See the per-type extraction note below and the Text extraction section. |
embed |
bool or array | Whether to generate embeddings. See the Text extraction, embeddings and search section. |
actions |
array | Custom actions to attach to the collection. |
models |
array | Per-model scoping (covered next). |
The models block: per-model scoping
By default any model using HasFiles can write to a collection with the same config. Declaring models restricts the collection to specific owner types and merges a per-model override on top of the base config. Resolution lives in EduLazaro\Laracrate\Support\CollectionConfig::resolve().
'documents' => [ 'disk' => 'documents', 'access' => 'signed', 'models' => [ // Cases get the stricter, viewer-bound stream access. 'case' => ['access' => 'stream', 'sensitive' => true], // Organizations keep signed access but skip PDF previews. 'organization' => ['types' => ['document' => ['preview' => false]]], ], ],
Semantics when models is present:
- Only the listed keys may use the collection. Keys are matched against the morph alias or the fully qualified class name, normalized through
Relation::morphMap(). A model not listed triggersEduLazaro\Laracrate\Exceptions\CollectionNotAllowedForModel. - The per-model override is merged over the base with
array_replace_recursive, so nested structures (likevariants) merge key by key. - The
modelskey itself is stripped from the resolved config. - A per-model override cannot relocate the binary. The object key is always
{fileable_morph}/{id}/{collection}/{file}(tenant-prefixed when the file has a tenant), built byCreateFileAction. It is not configurable through apathkey. - Resolving with no model (
CollectionConfig::resolve($collection)with a null second argument) returns the base config without merging, which is what tooling that iterates collections without a model context receives.
You can check whether a collection is scoped with CollectionConfig::isRestricted($collection).
Per-type extract and embed
The extract and embed keys accept either a boolean or an array, resolved by EduLazaro\Laracrate\Support\ExtractionResolver:
'extract' => true, // all types in the collection 'extract' => ['document', 'image'] // only these file types 'extract' => ['video.visual'] // an opt-in extra, matched by prefix
When the value is an array, it matches the file's type by exact value or by the type. prefix (so video matches video.visual and vice versa). The legacy boolean key extract_text is still honored for backward compatibility. Apps can register a per-file override with ExtractionResolver::setOverrideResolver(callable), where the callable receives the File and returns an override array (or null). See the Text extraction section.
Placeholders
The fallback chain when a file or variant does not exist or is not the type the render expects. Override these in your published config.
'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', ],
Resolution runs from most specific to most general:
config('laracrate.collections.{name}.placeholder')config('laracrate.placeholders.{type}')config('laracrate.placeholders.default')
Dynamic placeholders (initials avatars, generated SVGs)
A placeholder can be a callable instead of a string. When it is, fileLink() and fileRender() invoke it with (string $collection, string $type, Model $model) and use the returned string as the URL. This is how you render a generated fallback (an initials avatar, a ui-avatars URL, a per-model SVG) when a model has no file.
Use a callable array ([Class::class, 'method']), not a Closure. Closures are not serializable, so a Closure placeholder breaks php artisan config:cache in production.
// config/laracrate.php 'collections' => [ 'avatar' => [ 'disk' => 'media', 'access' => 'public', 'single' => true, 'placeholder' => [\App\Support\InitialsAvatar::class, 'placeholderFor'], ], ],
// app/Support/InitialsAvatar.php namespace App\Support; class InitialsAvatar { // The signature the package calls: (collection, type, model). public static function placeholderFor($collection, $type, $model): ?string { return self::dataUri($model?->name); } public static function dataUri(?string $name, int $size = 200): ?string { $name = trim((string) $name); if ($name === '') { return null; } $initials = strtoupper(mb_substr($name, 0, 1)); // 1 to 2 letters from the name $bg = '#1E40AF'; // derive a color from the name if you want variety $font = (int) round($size * 0.42); $svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' . $size . ' ' . $size . '">' . '<rect width="100%" height="100%" fill="' . $bg . '"/>' . '<text x="50%" y="50%" fill="#fff" font-size="' . $font . '" font-family="sans-serif"' . ' text-anchor="middle" dominant-baseline="central">' . htmlspecialchars($initials, ENT_QUOTES | ENT_XML1) . '</text></svg>'; return 'data:image/svg+xml;base64,' . base64_encode($svg); } }
Now $user->fileLink('avatar', 'small') returns the uploaded avatar when it exists, or the inline initials SVG when it does not, with no null checks in your views. A callable placeholder needs the model, so it resolves only through fileLink() and fileRender() (which carry it), not through the bare $file->placeholderFor(). String placeholders work everywhere.
URL strategy
TTLs and cache windows for the URL accessors. See the Displaying files and Access control sections for how each mode uses them.
'urls' => [ 'signed_ttl' => 5, 'signed_cache_ttl' => 4, 'sensitive_redirect_ttl' => 10, 'route_signed_ttl' => 15, 'bind_to_user' => true, ],
| Key | Default | Meaning |
|---|---|---|
signed_ttl |
5 |
Minutes a backend signed URL stays valid (for access: signed). |
signed_cache_ttl |
4 |
Minutes the server-side cache of a signed URL is kept. |
sensitive_redirect_ttl |
10 |
Seconds for the ultra-short signed URL issued after validation in the stream controller. |
route_signed_ttl |
15 |
Minutes the route HMAC for /laracrate/files/{slug}/stream stays valid. |
bind_to_user |
true |
Binds stream URLs to the user identity, re-validating on demand. |
Policies
The package uses PolicyRegistry as the canonical place to declare authorization per fileable_type. When register_gate is true (default), the service provider also binds FilePolicy to the Laravel Gate so you can use the native ergonomics.
'policies' => [ 'register_gate' => true, ],
With the bridge on, the Gate abilities view, update, and delete map to the registry methods canView, canEdit, and canDelete, so @can('view', $file), $user->can('update', $file), and Route::middleware('can:view,file') all work. Set register_gate => false if your app already registers its own FilePolicy. See the Access control and authorization section.
Streaming
Routing and audit options for the controller that serves access: stream files.
'stream' => [ 'route_prefix' => 'laracrate/files', 'route_name_prefix' => 'laracrate.files', 'middleware' => ['web', 'auth'], 'increment_downloads' => true, 'log_access' => true, ],
The laracrate/files prefix avoids collisions with an existing FileController under /files/..., which is common. increment_downloads bumps the file download counter, and log_access audits each download. See the HTTP endpoints section.
Status polling
Endpoints to poll processing status after an async upload.
'status' => [ 'route_prefix' => 'laracrate/files', 'middleware' => ['web', 'auth'], ],
GET /laracrate/files/{slug}/status returns one file, POST /laracrate/files/status accepts a batch of slugs. See the HTTP endpoints section.
Uploads
Routing and a disk allowlist for the direct presigned upload endpoints. The multipart block inherits this middleware when its own is null.
'uploads' => [ 'route_prefix' => 'laracrate/uploads', 'middleware' => ['web', 'auth'], 'allowed_disks' => [], ],
| Key | Default | Meaning |
|---|---|---|
route_prefix |
laracrate/uploads |
URL prefix for the presign and cancel endpoints. |
middleware |
['web', 'auth'] |
Middleware for the upload route group. Authorization is your app's responsibility. |
allowed_disks |
[] |
Allowlist of disks a client may upload to directly, enforced in the presign and multipart init endpoints. Empty means no restriction. |
These are the defaults; override them in your published config to restrict allowed_disks or change the prefix or middleware. See the HTTP endpoints and Upload modes sections.
Multipart upload
Tuning for large uploads to S3/R2. The server does not force multipart; the frontend chooses based on file.size. These values are the recommended thresholds.
'multipart' => [ 'threshold' => 100 * 1024 * 1024, // 100 MB 'part_size' => 10 * 1024 * 1024, // 10 MB 'expire_minutes' => 60, 'url_ttl_minutes' => 60, 'route_prefix' => 'laracrate/multipart', 'middleware' => null, ],
| Key | Default | Meaning |
|---|---|---|
threshold |
100 MB | Below this the client should use a single presigned PUT, at or above it multipart. |
part_size |
10 MB | Bytes per part (S3 minimum is 5 MB). 10 MB means 100 parts for 1 GB, 800 for 8 GB. |
expire_minutes |
60 |
TTL of the multipart session. After it, laracrate:abort-stale-multipart aborts it. |
url_ttl_minutes |
60 |
TTL of the per-part presigned URLs. |
route_prefix |
laracrate/multipart |
URL prefix for the multipart endpoints. |
middleware |
null |
Middleware for the route group. null inherits from the uploads block. |
See the Upload modes and HTTP endpoints sections.
Image
Image processing options used by the optimize and variant steps. Do not confuse image.driver (used for variants and optimization) with pdf_preview_engine (used for PDF rasterization).
'image' => [ 'driver' => 'imagick', 'optimize_originals' => false, 'max_width' => 1920, 'max_height' => 1920, 'quality' => 85, ],
driver is imagick (recommended) or gd. When optimize_originals is true, the original is re-encoded to webp within max_width/max_height at quality. See the Images, variants and watermarks section.
PDF preview engine
Selects the engine that rasterizes a PDF page into a PNG for the preview variant.
'pdf_preview_engine' => 'auto',
| Value | Requirements | Notes |
|---|---|---|
pdftoppm |
poppler-utils (apt install poppler-utils) |
Does not need Ghostscript or any change to ImageMagick policy.xml. |
imagick |
PHP imagick extension, Ghostscript (gs), and the PDF coder enabled in ImageMagick policy.xml |
Heavier setup. |
auto |
tries pdftoppm, falls back to imagick |
Default. |
You can override the engine per collection inside the preview block:
'preview' => ['page' => 1, 'width' => 600, 'engine' => 'pdftoppm'],
See the Video and PDF previews section.
Video
Defaults for ffmpeg-based transcoding when a collection does not override them.
'video' => [ 'max_width' => 1920, 'max_height' => 1920, 'bitrate_kbps' => 2500, ],
See the Video and PDF previews section.
Encryption
Driver used to encrypt the binary for collections with encrypt: true.
'encryption' => [ 'driver' => 'laravel', ],
laravel uses the framework Crypt facade. See the Sensitive content and encryption section.
Embeddings
Opt-in text extraction and vector embeddings. enabled is the master switch: when false, nothing is embedded even if a collection asks for it.
'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, 'extractors' => [], 'min_text_per_file' => 100, ],
| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Master switch for the whole feature. |
provider |
openai |
Provider implementing EmbeddingProvider. The package ships an OpenAI provider; the real binding is done in LaracrateServiceProvider. |
api_key |
env LARACRATE_EMBEDDINGS_API_KEY |
If null, the OpenAI provider falls back to OPENAI_API_KEY. |
model |
text-embedding-3-small |
Provider model, overridable per environment. |
dimensions |
1536 |
Vector dimensions. Fixed by the model, change only when you change the model. |
chunk_size |
1000 |
Approximate tokens per chunk. 0 disables chunking (one row per file). |
chunk_overlap |
100 |
Token overlap between consecutive chunks. |
batch_size |
16 |
Chunks per request to the provider. |
extractors |
[] |
Ordered chain of text extractors. Empty means the built-in defaults. |
min_text_per_file |
100 |
Minimum characters an extractor must produce to count as successful. Below this, the next extractor in the chain is tried. |
The extractors chain runs in order; if one returns less than min_text_per_file characters, the next is tried. A typical scanned-PDF chain is PdfTextExtractor (fast, native PDFs) then OcrPdfTextExtractor (LLM OCR) then PlainTextExtractor. See the Text extraction, embeddings and search (RAG) section for the full behavior.
Chunks
Selects the ChunkStore backend that persists and searches text chunks.
'chunks' => [ 'driver' => env('LARACRATE_CHUNKS_DRIVER', 'mysql'), ],
| Driver | Storage | Notes |
|---|---|---|
mysql |
laracrate_file_chunks (SQL LIKE keyword match plus cosine similarity in PHP) |
No external dependencies. Scales well up to roughly 5K chunks per scope. |
meilisearch |
A Meilisearch index with user-provided embeddings | Native hybrid search (BM25 plus vector) with semanticRatio server-side. Requires meilisearch/meilisearch-php and a Meilisearch\Client binding in your app. |
Custom backends (Qdrant, pgvector) can bind ChunkStore directly. See the Text extraction, embeddings and search (RAG) section.
Meilisearch
Applies only when chunks.driver is meilisearch.
'meilisearch' => [ 'index' => env('LARACRATE_MEILISEARCH_INDEX', 'laracrate_file_chunks'), 'embedder' => env('LARACRATE_MEILISEARCH_EMBEDDER', 'default'), ],
OCR
Config for OcrPdfTextExtractor, the fallback for scanned PDFs. The provider is selectable via env, and each provider's API key falls back to the generic key for that provider.
'ocr' => [ 'provider' => env('LARACRATE_OCR_PROVIDER', 'anthropic'), // Fallback language for the auto-generated image description when the // image has no visible text to infer the language from (image OCR only). 'locale' => 'en', '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')), ], ],
provider is anthropic (default, model claude-haiku-4-5) or openai (model gpt-4o-mini). locale only affects image OCR (OcrImageTextExtractor): the image description follows the visible text's language, falling back to this locale when the image has no text. See the Text extraction section.
Watermark
Settings for the watermark embedded into specific variants. The original (master) is never watermarked; only variants that declare 'watermark' => true are. The defaults here are global, applied wherever a variant opts in.
'watermark' => [ 'image_path' => env('LARACRATE_WATERMARK_IMAGE', null), 'size' => 0.40, 'opacity' => 30, 'position' => 'center', 'text' => [ 'content' => null, 'font_size_ratio' => 0.0195, 'color' => 'rgba(255, 255, 255, 0.60)', 'position' => 'bottom-left', 'padding' => 20, 'font_path' => null, ], ],
| Key | Default | Meaning |
|---|---|---|
image_path |
env LARACRATE_WATERMARK_IMAGE, else null |
Absolute path or path relative to public_path() of the PNG to overlay. null applies no image. |
size |
0.40 |
Watermark width as a fraction of the variant width (0.0 to 1.0). |
opacity |
30 |
Overlay opacity (0 to 100). |
position |
center |
One of center, top-left, top-right, bottom-left, bottom-right. |
text.content |
null |
Optional auxiliary text: null, a fixed string, or a closure(File): ?string for dynamic text (set via a provider or published config, not env). |
text.font_size_ratio |
0.0195 |
Font size as a fraction of the image width. |
text.color |
rgba(255, 255, 255, 0.60) |
CSS rgba color. |
text.position |
bottom-left |
One of bottom-left, bottom-right, top-left, top-right. |
text.padding |
20 |
Padding from the edge, in pixels. |
text.font_path |
null |
Path to a .ttf font, or null for the system font. |
See the Images, variants and watermarks section for the mechanics.
UI
Default theme for the <livewire:laracrate-uploader> component when no theme= prop is passed.
'ui' => [ 'default_theme' => env('LARACRATE_THEME', 'default'), ],
The built-in themes are default, brutalist, material, ios, glassmorphism, neon, minimal, neumorphism, chatgpt, claude, and studio. For a custom theme, publish the views with vendor:publish --tag=laracrate-views and add your blade under resources/views/vendor/laracrate/uploader/themes/. See the Livewire components and themes section.
Queue
Routing for the package jobs (variants, previews, embeddings) dispatched by ProcessFileJob.
'queue' => [ 'connection' => env('LARACRATE_QUEUE_CONNECTION', null), 'name' => env('LARACRATE_QUEUE_NAME', 'default'), ],
connection of null uses your default queue connection. All processing runs on the queue by design, so the user's upload stays instant. See the Processing pipeline section.
Working with files from your models
The HasFiles trait is the main API surface you interact with day to day. Add it to any Eloquent model (User, Property, Service, Organization) and that model can hold files grouped into named collections (avatar, gallery, documents, and so on). Files attach through a polymorphic relation, so one model can own many collections at once.
use EduLazaro\Laracrate\Concerns\HasFiles; class User extends Authenticatable { use HasFiles; }
Collections are declared in config/laracrate.php (see the Configuration section). You usually do not need to do anything else on the model. If you want per-model tweaks to a collection (a different disk, a different placeholder, a render component), declare the optional $fileCollections property. It is merged recursively over the base config (array_replace_recursive), so you only override the keys you care about:
class User extends Authenticatable { use HasFiles; protected array $fileCollections = [ 'avatar' => [ 'component' => 'user-avatar', 'placeholder' => '/img/default-avatar.png', ], ]; }
Adding files
Use addFile() to append a file to a collection. It accepts an UploadedFile, a local path string, a Binary, or a FileUpload (the value object returned by direct-to-bucket uploads, see the Upload modes section).
$user->addFile($request->file('photo'), 'gallery');
The full signature:
public function addFile( UploadedFile|Binary|FileUpload|string $file, string $collection, array $data = [], array $slots = [], ?Model $creator = null, ?Model $owner = null, ?Folder $folder = null, ): ?File
| Argument | Purpose |
|---|---|
$file |
The upload source: UploadedFile, path string, Binary, or FileUpload. |
$collection |
The collection name, must be declared in config. |
$data |
Per-file attributes (see below). |
$slots |
File slot keys to attach the file to (see the File slots section). |
$creator |
Who created it. Defaults to auth()->user(). |
$owner |
Semantic owner when different from the creator. |
$folder |
A Folder belonging to this same model (see the Folders section). |
The $data array maps to dedicated columns: title, description, category, visibility, label, default, position, plus a metadata key that is stored as-is in the JSON metadata column. Any other key throws InvalidArgumentException, so a typo fails loudly instead of being silently dropped. To store arbitrary data, nest it under metadata.
$user->addFile($request->file('cv'), 'documents', [ 'title' => 'Resume 2026', 'category' => 'application', 'metadata' => ['source' => 'web'], ]);
Processing (variants, previews, text extraction, embeddings) runs asynchronously on the queue after the file is created, so addFile() returns instantly. See the Processing pipeline section.
Replacing a single file
Use setFile() for "one file per collection" cases like an avatar or a logo. It force-deletes every existing file in the collection (and their variants), then adds the new one. Pass null to clear the collection without adding anything.
$user->setFile('avatar', $request->file('avatar')); $user->setFile('avatar', null); // remove the current avatar
public function setFile( string $collection, UploadedFile|Binary|FileUpload|string|null $file, array $data = [], ?Model $creator = null, ?Model $owner = null, ): ?File
Deleting and reordering
$user->deleteFile($file); // soft delete $user->deleteFile($file, forceDelete: true); // also purge the binary now $user->reorderFiles('gallery', [42, 17, 9]); // assigns position 0, 1, 2 by index
reorderFiles() takes the file IDs in the order you want and writes position by array index. It only touches top-level files that belong to this model and collection, so it is safe to drive directly from a drag-and-drop UI.
Defaults
A collection can mark one file as its default (the chosen avatar among several uploads, the cover of a gallery). Set it from the parent model or from the file itself:
$user->setDefaultFile($file); // unsets any other default in the collection, returns the fresh File $file->makeDefault(); // same, called on the File model
Querying files
| Method | Returns | Notes |
|---|---|---|
files(?string $collection = null) |
MorphMany |
Top-level files ordered by position then id. Filter by collection when passed. |
file(string $collection) |
?File |
The default (if any) or otherwise the most recent file in the collection. |
defaultFile(string $collection) |
?File |
Only the file flagged default = true. |
images(?string $collection = null) |
MorphMany |
Files where type = image. |
$avatar = $user->file('avatar'); // single best file $gallery = $user->files('gallery')->get(); // all, in order $photos = $user->images()->get(); // every image across collections
files() and images() return query builders, so you can keep chaining (->where(...), ->paginate(...)). file() and defaultFile() execute and return a File or null.
Displaying files
Once a file exists, you render it from a URL. Every File resolves its own URL based on the collection access mode (public CDN, signed URL, or streamed), so you do not build paths by hand. The same call works for every access mode (see the Access control section for what each mode does).
The simplest path is to grab the file and read its url():
@php($avatar = $user->file('avatar')) @if ($avatar) <img src="{{ $avatar->url() }}" alt="Avatar"> @endif
For Blade convenience the model exposes accessors so you can skip the method call:
| Accessor | Equivalent to |
|---|---|
$file->link |
$file->url() |
$file->preview_link |
$file->variant('preview.thumbnail')->url('image'), falling back to the image placeholder |
<img src="{{ $file->link }}"> <img src="{{ $file->preview_link }}"> {{-- thumbnail for video, PDF, audio, image --}}
preview_link never throws: if the disk is unreachable or the variant is missing, it returns the configured placeholder so one broken file cannot break the page.
Navigating variants
A file can have derived variants (a thumbnail, a small image, a video preview, and so on, created by the pipeline). Reach them with variant() using dot notation:
$file->variant('thumbnail')->url(); $file->variant('preview.small')->url('image');
variant() never returns null. If any link in the chain is missing it falls back to the closest existing ancestor, so $file->variant('preview.small') returns the preview (or the original) when small was not generated yet. When that silent fallback would be a bug, use variantOrFail() instead, which throws RuntimeException if any segment is missing:
$thumb = $file->variantOrFail('preview.thumbnail'); // throws if not generated
To avoid lazy-loading each variant on access, eager load the tree with the withVariants() scope on the File model:
$files = File::withVariants()->get(); // loads file -> preview -> thumbnail|small|...
url() and forced types
url() returns the real URL, or null when no valid backend resolves. Pass a type to force a fallback: if the file is not that type, or its URL cannot be built, you get the configured placeholder for that type instead of null.
$file->url(); // real URL or null $file->url('image'); // real URL if it is an image, otherwise the image placeholder
Rendering from the parent model
The trait gives you two helpers that go straight from a model + collection to a renderable result, with placeholder fallback built in. Prefer these when you do not have a File instance in hand.
fileLink() returns a URL string (the file, a variant, or a placeholder), so the <img> always has a src:
<img src="{{ $user->fileLink('avatar') }}"> <img src="{{ $user->fileLink('avatar', 'medium') }}"> {{-- a variant --}} <img src="{{ $user->fileLink('cover', 'preview.thumbnail') }}">
public function fileLink(string $collection, ?string $variant = null, ?string $forceType = null): ?string
$forceType selects which placeholder to fall back to. You only need it for multi-type collections (for example a gallery that accepts both images and video). When a collection declares a single type in config, the type is inferred for you.
fileRender() returns rendered HTML. With no component configured it emits a plain <img>; with a component configured for the collection it renders that component instead. Extra attributes are forwarded:
{{ $user->fileRender('avatar', 'medium', ['class' => 'w-12 h-12 rounded-full']) }}
public function fileRender(string $collection, ?string $variant = null, array $attrs = []): HtmlString
Per-collection components
For a consistent look per collection (a round avatar with initials fallback, a card for documents), point the collection at a Blade component via the component config key (or the $fileCollections override shown above). fileRender() then renders:
<x-{component} :model="$model" :url="$url" ...attrs />
The component receives $model (the owning model) and $url (the resolved URL, which may be null when there is no file and no placeholder). A minimal component for the avatar example above:
{{-- resources/views/components/user-avatar.blade.php --}} @props(['model', 'url']) @if ($url) <img src="{{ $url }}" alt="{{ $model->name }}" {{ $attributes->merge(['class' => 'rounded-full']) }}> @else <span {{ $attributes->merge(['class' => 'rounded-full bg-gray-200 grid place-items-center']) }}> {{ Str::of($model->name)->substr(0, 1)->upper() }} </span> @endif
With that component registered, {{ $user->fileRender('avatar') }} renders the avatar everywhere, and falls back to the initials badge when the user has no photo.
Upload modes
Laracrate gives you three ways to get a file's bytes into your storage backend. They differ in who carries the bytes (your PHP server or the browser talking straight to S3/R2) and how large the file is. In every mode the end result is the same: a row in laracrate_files created through $model->addFile(...), with the binary already at its canonical key.
| Mode | How the bytes travel | Best for | Pros | Cons |
|---|---|---|---|---|
| Server-side | Browser to your PHP, then PHP to the disk | Small files, simple forms, trusted server flows | One request, no JS, works on any disk | Bytes pass through PHP (memory, request size limits) |
| Direct presigned PUT | Browser straight to S3/R2 via a presigned URL | Most uploads up to roughly 100 MB | Offloads bandwidth from PHP, progress events | Needs S3-compatible disk (or the local fallback), two steps |
| Multipart | Browser uploads in parts straight to S3/R2 | Large files (video, archives) | Parallel parts, resumable, no PHP transfer | S3/R2 only, more orchestration |
The threshold between presigned and multipart is just a frontend hint, config('laracrate.multipart.threshold') (100 MB by default). The server does not enforce it. Your client code decides which path to take based on file.size.
Server-side (addFile with an UploadedFile)
The simplest mode. Hand addFile() the UploadedFile straight from the request and Laracrate writes it to the collection's disk for you.
public function store(Request $request) { $request->validate(['avatar' => 'required|image|max:5120']); $file = $request->user()->addFile($request->file('avatar'), 'avatar'); return back(); }
addFile() also accepts a Binary value object, a FileUpload (see below), or a string key. See the Working with files from your models section for the full signature and the $data, $slots, $folder parameters.
Direct presigned PUT
Here the browser uploads straight to S3/R2 and your server never touches the bytes. The flow is: ask the server for a presigned URL, PUT the file to it, then confirm by sending the resulting key back so addFile() can persist the File row.
The JS helper ships at resources/js/laracrate.js inside the package (it is not published to npm). Copy it into your app's JS sources, or point a bundler alias at vendor/edulazaro/laracrate/resources/js/laracrate.js, then import presignAndUpload, which does the presign request and the PUT (with progress) in one call:
import { presignAndUpload } from './laracrate'; const result = await presignAndUpload(file, { disk: 'media', maxSizeKb: 10240, onProgress: (ratio) => console.log(Math.round(ratio * 100) + '%'), }); // result = { key, disk, original_name, mime_type, size } // Send it to your own controller to confirm. await fetch('/profile/avatar', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, }, body: JSON.stringify(result), });
presignAndUpload(file, opts) options: disk (required), collection, fileable ({ type, id }), maxSizeKb, presignUrl (override the presign route), and onProgress (a 0..1 callback). It returns { key, disk, original_name, mime_type, size }.
On the backend, rebuild a FileUpload from that payload and pass it to addFile():
use EduLazaro\Laracrate\Support\FileUpload; public function confirm(Request $request) { $data = $request->validate([ 'disk' => 'required|string', 'key' => 'required|string', 'original_name' => 'required|string', 'mime_type' => 'required|string', 'size' => 'required|integer', ]); $file = $request->user()->addFile( FileUpload::fromArray($data), 'avatar' ); return response()->json(['slug' => $file->slug]); }
FileUpload::fromArray() accepts disk, key, original_name (or originalName), mime_type (or mimeType), size, and optional width, height, duration, digest.
Where the file lands. The presign endpoint chooses the key two ways. If you pass fileable_type, fileable_id, and collection, the file is uploaded straight to its canonical key ({fileable_type}/{fileable_id}/{collection}/{ulid}_{name}), so no move is needed afterward. If you do not (the common case for a creation form with no model yet), it lands under temp/. When you then call addFile() with that temp/ key, Laracrate moves the object to its canonical key. On S3-compatible disks the move is a server-side copyObject plus deleteObject (StorageManager::moveServerSide()): the bytes never come back through PHP.
If the user cancels before confirming, delete the orphaned temp/ object with deleteTemp(disk, key) from the JS helper. It only deletes keys that start with temp/.
Multipart (large files)
For files past the threshold, S3/R2 multipart splits the upload into parts that the browser PUTs in parallel, each returning an ETag. Laracrate exposes the orchestration endpoints; there is no bundled multipart JS helper, so you drive the four endpoints yourself based on file.size:
- Init.
POST /laracrate/multipart/initwithdisk,expected_size, and optionallymime,file_name,part_size,fileable_type,fileable_id,collection. The response gives youupload_id,id,key,part_size,total_parts,expires_at, andparts(an array of{ part_number, url, method }). - Upload parts.
PUTeach part's bytes to itsurl. Capture theETagresponse header for every part. - Reissue (optional). If a part URL expires before you use it,
POST /laracrate/multipart/{id}/partswithpart_numbersto get fresh URLs. - Complete.
POST /laracrate/multipart/{id}/completewithpartsas[{ part_number, etag }, ...]. S3 assembles the object atkey. To cancel instead,DELETE /laracrate/multipart/{id}.
After complete, the binary exists at key but no File row has been created yet. Persist it the same way as a presigned upload, by calling addFile(FileUpload::fromArray([...]), $collection) with the final disk and key. Multipart sessions are tracked in laracrate_multipart_uploads; only the creator can complete or abort a session, and stale ones are reaped by laracrate:abort-stale-multipart (see the artisan commands section).
Multipart requires an S3-compatible disk. Tuning lives under config('laracrate.multipart'): part_size (10 MB default, 5 MB minimum), expire_minutes, and url_ttl_minutes.
Local driver for development
You usually do not have S3/R2 in local dev. When the upload disk uses Laravel's local driver, StorageManager::presignedUpload() cannot mint a true presigned URL, so it returns a Laravel signed route to the local upload endpoint instead (POST instead of PUT). presignAndUpload follows whatever URL and method the presign response specifies, so the same client code works against local storage with no changes. The signed-route endpoints serving this are described in the next section.
HTTP endpoints
Laracrate registers its routes in routes/web.php. They cover four concerns: direct uploads (presign and multipart), streaming and downloading protected files, polling processing status, and the local-driver upload/serve fallback. Each group has its own prefix, middleware, and route-name prefix driven by config, so you can move or re-protect them without touching the package.
You rarely call these routes by name from PHP. The JS helper and your upload flow hit them by URL. The full set:
| Method | URI | Route name | Purpose |
|---|---|---|---|
| POST | laracrate/uploads/presign |
laracrate.uploads.presign |
Mint a presigned PUT URL (or local signed-route fallback) for a direct upload |
| DELETE | laracrate/uploads/{disk}/{encodedKey} |
laracrate.uploads.cancel |
Delete an abandoned temp/ object (encodedKey is base64 then URL-encoded) |
| POST | laracrate/multipart/init |
laracrate.multipart.init |
Start a multipart session, returns upload_id, total_parts, and part URLs |
| POST | laracrate/multipart/{multipart}/parts |
laracrate.multipart.parts |
Reissue presigned URLs for specific parts |
| POST | laracrate/multipart/{multipart}/complete |
laracrate.multipart.complete |
Assemble the final object from uploaded part ETags |
| DELETE | laracrate/multipart/{multipart} |
laracrate.multipart.abort |
Abort and clean up a multipart session |
| GET | laracrate/files/{file:slug}/stream |
laracrate.files.stream |
Stream a protected file inline (audit + viewer bind) |
| GET | laracrate/files/{file:slug}/preview |
laracrate.files.preview |
Stream the file's preview variant |
| GET | laracrate/files/{file:slug}/download |
laracrate.files.download |
Download a protected file as an attachment |
| GET | laracrate/files/{file:slug}/status |
laracrate.files.status |
Processing status for one file (JSON) |
| POST | laracrate/files/status |
laracrate.files.status.batch |
Processing status for many files in one request |
| POST | _laracrate/local/upload |
laracrate.local.upload |
Receive bytes for the local-driver presigned fallback (signed) |
| GET | _laracrate/local/serve/{file:slug} |
laracrate.local.serve |
Serve a local-disk file after verifying the URL signature (signed) |
Prefixes and middleware. Each group reads its own config, so prefixes and route names shown above are the defaults:
| Group | Prefix config | Middleware config | Name prefix |
|---|---|---|---|
| Presigned uploads | laracrate.uploads.route_prefix (laracrate/uploads) |
laracrate.uploads.middleware (['web', 'auth']) |
laracrate.uploads. |
| Multipart | laracrate.multipart.route_prefix (laracrate/multipart) |
laracrate.multipart.middleware (falls back to the uploads middleware when null) |
laracrate.multipart. |
| Stream / preview / download | laracrate.stream.route_prefix (laracrate/files) |
laracrate.stream.middleware (['web', 'auth']) |
laracrate.stream.route_name_prefix (laracrate.files) |
| Status | laracrate.status.route_prefix (laracrate/files) |
laracrate.status.middleware (['web', 'auth']) |
laracrate.files. |
| Local driver | fixed _laracrate/local |
signed |
laracrate.local. |
A few details worth knowing:
- Authorization is yours. The upload and multipart groups only apply the configured middleware. Restricting which disks a user may write to is enforced separately by
config('laracrate.uploads.allowed_disks'), checked in the presign and init endpoints (empty means no restriction). cancelonly touchestemp/. The DELETE endpoint refuses any key that does not start withtemp/, so it cannot be used to delete canonical objects. ThedeleteTemp()JS helper builds the{encodedKey}segment for you.- Multipart ownership.
parts,complete, andabortverify the caller is the session's creator (when a creator was recorded at init), returning 403 otherwise. - Local routes are signed, not authed. The
_laracrate/localgroup is protected by Laravel'ssignedmiddleware. The URLs are minted byStorageManager::presignedUpload()andGenerateSignedUrlAction, so they carry their own expiry. They exist only as the dev-time stand-in for real presigned S3/R2 URLs. - Status responses. Both status endpoints check
canView()per file and return{ slug, status, ready, url, preview, variants, error }per file (the batch endpoint keys the map by slug and silently omits files you cannot view). Use thepollFileStatusandpollFilesStatusJS helpers to consume them; see the displaying files and processing pipeline sections for howreadyandvariantsare populated.
Folders
Folders give a model a tree of named folders to organize its files. They are purely logical: a folder never changes where the binary lives in your disk (a file's storage key stays the same when you move it between folders). Use folders when you want a drive-like UI on top of a fileable (a user's personal drive, an organization's shared drive, a per-case document tree).
Add the HasFolders trait to any model. It pairs with HasFiles but is independent: a model can use one, the other, or both.
use EduLazaro\Laracrate\Concerns\HasFiles; use EduLazaro\Laracrate\Concerns\HasFolders; class Organization extends Model { use HasFiles; use HasFolders; }
Creating folders
Call addFolder() on the model. Pass a parent Folder to nest, or leave it null to create a root folder. The folder's polymorphic folderable_* morph is set to the owning model automatically.
$contracts = $organization->addFolder('Contracts'); $y2025 = $organization->addFolder('2025', parent: $contracts); // Optional audit and metadata: $folder = $organization->addFolder( name: 'Invoices', parent: null, creator: auth()->user(), // defaults to auth()->user() when null metadata: ['color' => '#a855f7'], );
addFolder() throws InvalidArgumentException if the $parent you pass belongs to a different folderable. The full signature is:
public function addFolder( string $name, ?Folder $parent = null, ?Model $creator = null, array $metadata = [] ): Folder
Listing the tree
| Call | Returns |
|---|---|
$organization->folders() |
MorphMany of every folder at any depth |
$organization->rootFolders() |
Only top-level folders (parent_id null), ordered by name |
$folder->children() |
Direct child folders (one level), ordered by name |
$folder->descendants() |
Query of all nested folders below, via the denormalized path (one indexed query, no SQL recursion) |
$folder->files() |
Files directly in this folder (not recursive) |
$folder->allFiles() |
Top-level files in this folder and all its descendants |
$folder->breadcrumb() |
Array of ancestor folders from root to this one, for breadcrumbs |
$folder->sizeBytes() |
Sum of size for every file in the subtree |
foreach ($organization->rootFolders as $folder) { echo $folder->name . ' (' . $folder->sizeBytes() . ' bytes)'; }
Putting files into folders
When you create a file, pass the target folder to addFile() (see the Working with files from your models section):
$organization->addFile($upload, 'drive', folder: $contracts);
To move an existing file, call moveToFolder() on the File. Pass null to move it back to the fileable root.
$file->moveToFolder($y2025); // into a folder $file->moveToFolder(null); // back to the root
Both addFile() and moveToFolder() throw InvalidArgumentException if the folder belongs to a different fileable, so a file can never be attached to another owner's folder.
Moving and renaming folders
Move a folder under a new parent with moveTo() (null moves it to root):
$y2025->moveTo($archive);
moveTo() refuses two things: moving a folder between different folderables, and any move that would create a cycle (making a folder a descendant of itself). Both raise InvalidArgumentException.
The denormalized path column (for example Contracts/2025) is the source of truth for fast listings, and parent_id is the source of truth for structure. The FolderObserver keeps them in sync: on every save it recomputes path from parent->path + name, and after an update it cascades the new path to all descendants. Renaming Contracts to Agreements rewrites Contracts/2025 to Agreements/2025 automatically. Setting path by hand is pointless because the observer overwrites it.
Deleting folders
Folder uses soft deletes. To remove a whole subtree permanently, call forceDeleteRecursive(). It force-deletes every file in the subtree first (which fires the FileObserver and purges the binaries plus chunks), then the descendant folders deepest-first, then the folder itself.
$contracts->forceDeleteRecursive();
The
folderablemorph backs two unrelated features. TheFoldermodel organizes files in a tree, while the separateFolderablemodel is a per-collection usage counter. They share the morph name but nothing else. Usage tracking is covered in the Multi-tenancy, buckets and usage section.
File slots
A file slot is a structured "you must upload X" requirement: a named target with rules about what can land in it and how many files it accepts. Use slots for things like an admission checklist ("Upload your ID", "Upload proof of address") or a quota ("Upload up to 3 invoices for June"). A slot does not classify or categorize your files, it only defines where files fit and under what rules. Categories, tags, and hierarchies stay in your app.
Slots live in laracrate_file_slots and link to files through the laracrate_file_slot_pivot table (many-to-many, so one file can satisfy several slots).
Defining a slot
Create a FileSlot directly. Every rule is optional; an empty rule means "no restriction".
use EduLazaro\Laracrate\Models\FileSlot; $slot = FileSlot::create([ 'name' => 'National ID', 'description' => 'Upload your ID document', 'allowed_extensions' => ['pdf', 'jpg', 'png'], 'allowed_types' => ['document', 'image'], 'max_files_per_creator' => 1, 'max_files_total' => null, 'tenant_type' => $org->getMorphClass(), 'tenant_id' => $org->getKey(), 'context_type' => $case->getMorphClass(), 'context_id' => $case->getKey(), ]);
| Column | Type | Purpose |
|---|---|---|
name |
string | Slot label shown to the uploader |
description |
string, nullable | Optional helper text |
color |
string, nullable | Optional UI color |
allowed_extensions |
array, nullable | Allowed file extensions (empty = any) |
allowed_types |
array, nullable | Allowed FileType values: document, image, video, audio (empty = any) |
max_files_per_creator |
int, nullable | Per-creator limit (null = unlimited) |
max_files_total |
int, nullable | Global limit across all creators (null = unlimited) |
position |
int | Display order, defaults to 0 |
tenant_type / tenant_id |
morph, nullable | Multi-tenant scope, same convention as File |
context_type / context_id |
morph, nullable | Optional finer scope inside the tenant (for example one case) |
Attaching files to slots
Pass the slots when you create the file. The $slots argument of addFile() accepts FileSlot models or their IDs, and they are validated before the file is written:
$organization->addFile($upload, 'documents', slots: [$slot]);
During creation Laracrate checks each slot's extension rule and quota. If the slot does not accept the file's extension, or the per-creator or global limit is already reached, addFile() throws InvalidArgumentException and no file is created. On success the file is attached with syncWithoutDetaching, so re-attaching is idempotent.
You can also manage the relation directly from either side:
$slot->files; // BelongsToMany of files in this slot $file->slots; // BelongsToMany of slots this file satisfies $file->slots()->syncWithoutDetaching([$slot->id]);
Checking rules and completion
FileSlot exposes the predicates used during upload, so you can drive UI and your own validation with the same logic.
| Method | Returns |
|---|---|
uploadedCount(?string $creatorType = null, ?int $creatorId = null) |
Number of files in the slot, optionally filtered by creator morph |
canAcceptMore(?string $creatorType = null, ?int $creatorId = null) |
Array ['can' => bool, 'reason' => 'global'|'per_creator'|null, 'limit' => int|null] |
acceptsExtension(string $extension) |
Whether the extension is allowed (empty list = any) |
acceptsType(string $type) |
Whether the FileType value is allowed (empty list = any) |
accepts(File $file) |
Full check on a file: extension AND type rules must both pass when both are declared |
// Is this required slot satisfied for the current user? $done = $slot->uploadedCount($user->getMorphClass(), $user->getKey()) >= 1; // Can the user add another file? $check = $slot->canAcceptMore($user->getMorphClass(), $user->getKey()); if (! $check['can']) { // $check['reason'] is 'global' or 'per_creator', $check['limit'] is the cap } // Pre-flight a file before showing an upload button: if ($slot->accepts($file)) { // extension and type both allowed }
uploadedCount() and canAcceptMore() count across all creators when you omit the creator arguments, so passing no arguments gives you the global totals while passing the creator morph scopes to one uploader.
Processing pipeline
Every top-level file goes through an asynchronous processing pipeline that runs in the queue, so the user's upload returns instantly while heavier work (image variants, video transcoding, PDF previews, text extraction, embeddings) happens in the background.
The flow is always the same:
- A top-level
Fileis created.FileObserver::createdsetsprocessing_statustopendingand dispatchesProcessFileJob. ProcessFileJobruns on the queue and callsProcessFileAction.ProcessFileActionmarks the fileprocessing(firingFileProcessingStarted), resolves the applicable steps, runs them in ascendingpriority()order, then marks the filecompletedand firesFileProcessed.
The observer only enqueues a job for files whose type is image, video, document, or audio. Variants (files with a parent_id) never enter the pipeline: their generating action marks them completed and the observer just fires VariantGenerated. See the Data model section for the processing_status column and the Events section for the dispatched events.
// You do not call this yourself. Creating a file triggers it. $file = $user->addFile($uploaded, 'documents'); // $file->processing_status is now ProcessingStatus::PENDING // A ProcessFileJob is queued. Once the worker runs it, // processing_status moves to PROCESSING then COMPLETED (or FAILED).
The ProcessingStatus enum (EduLazaro\Laracrate\Enums\ProcessingStatus) has four cases: PENDING, PROCESSING, COMPLETED, FAILED, plus helpers isTerminal() and isInProgress(). If the queue is not running in development, the file stays pending until a worker picks it up. That is expected behavior, not a bug.
Default steps
Each step is a small class implementing EduLazaro\Laracrate\Contracts\FileActionInterface. The package registers these globally in LaracrateServiceProvider, ordered here by priority():
| Priority | Step class (namespace EduLazaro\Laracrate\Pipeline\Steps\...) |
Runs when |
|---|---|---|
| 10 | Image\ExtractImageDimensionsStep |
type is image |
| 10 | Video\ExtractVideoDimensionsStep |
type is video |
| 20 | Image\OptimizeImageStep |
type is image and optimization is enabled (collection optimize, type optimize, or laracrate.image.optimize_originals) |
| 25 | Video\TranscodeVideoStep |
type is video and the video type config sets transcode |
| 40 | Image\GenerateImageVariantsStep |
type is image and the image type config declares variants |
| 45 | Video\ExtractVideoPreviewStep |
type is video and the video type config sets preview |
| 45 | Document\ExtractPdfPreviewStep |
type is document, mime_type is application/pdf, and the document type config sets preview |
| 60 | Text\ExtractTextStep |
embeddings enabled, the collection should extract or embed, and a text extractor exists for the file |
| 70 | Text\ChunkTextStep |
embeddings enabled, the collection should embed, and the {key}.json sidecar exists |
| 80 | Text\GenerateEmbeddingStep |
embeddings enabled, the collection should embed, and the {key}.chunks.jsonl sidecar exists |
| 90 | Text\PersistChunksStep |
the {key}.chunks.jsonl sidecar exists |
The text steps write two sidecar artifacts next to the binary on the same disk: {key}.json (extracted full text plus per-page content) and {key}.chunks.jsonl (chunks and embeddings). Each later step gates on the artifact the previous one produced, so the chain stops cleanly if extraction yields nothing. These sidecars are purged when the file is force deleted. See the Images, variants and watermarks, Video and PDF previews, and Text extraction, embeddings and search (RAG) sections for what each step actually does and the config keys it reads.
Priority bands
priority() returns an ascending integer. Lower numbers run first. Follow this convention so your steps slot in at the right point:
| Band | Purpose |
|---|---|
| 0-19 | Metadata (dimensions, duration) |
| 20-39 | Transforming the original (optimize, transcode, encrypt) |
| 40-59 | Derivatives (variants, previews, thumbnails) |
| 60-79 | Semantic extraction (text, OCR, transcription) |
| 80-99 | AI (chunking, embeddings, classification) |
| 100+ | App-specific post-processing |
Failure and retry behavior
The pipeline is fail-fast. If any step throws, ProcessFileAction marks the file failed, stores the exception message in processing_error, fires FileProcessingFailed, and rethrows. Later steps do not run.
The rethrow lets the queue retry. ProcessFileJob declares:
public int $tries = 3;public array $backoff = [10, 30, 60];(seconds between attempts)public int $timeout = 600;public bool $deleteWhenMissingModels = true;
$deleteWhenMissingModels matters when a file is replaced before the worker reaches its job (for example setFile() swapping an avatar): Laravel silently discards the orphaned job instead of failing three times with ModelNotFoundException. Any job you add that receives a model should set it too. The job's queue name and connection come from laracrate.queue.name and laracrate.queue.connection (see the Configuration section).
Writing a step
A step decides whether it applies (supports(), optional) and what to do (handle()), and declares its order (priority()). The supports(File $file): bool method is optional: if you omit it, handle() always runs. Scope by file type, collection, or model inside supports(); throw from handle() to fail the pipeline.
<?php namespace App\Pipeline\Steps; use EduLazaro\Laracrate\Contracts\FileActionInterface; use EduLazaro\Laracrate\Enums\FileType; use EduLazaro\Laracrate\Models\File; class ScanForVirusesStep implements FileActionInterface { public function supports(File $file): bool { return $file->type === FileType::DOCUMENT; } public function priority(): int { return 5; // run before everything else } public function handle(File $file): void { // Inspect $file->key on $file->disk. Throw to fail the pipeline. } }
Steps run on the queue worker, so this is also the only place you should shell out to external binaries (ffmpeg, imagick, pdftoppm). Keep that work out of the observer and out of CreateFileAction.
There are two extension points.
(a) Register a step globally
Add it to the FileActionRegistry from your own service provider's boot(). It then applies to every file (subject to its own supports()), and remove() lets you drop a packaged default by class name.
use App\Pipeline\Steps\ScanForVirusesStep; use EduLazaro\Laracrate\Pipeline\Steps\Image\OptimizeImageStep; use EduLazaro\Laracrate\Support\FileActionRegistry; public function boot(): void { app(FileActionRegistry::class) ->add(new ScanForVirusesStep()) ->remove(OptimizeImageStep::class); }
(b) Declare a step per collection or per model
Declare the step class under a collection's actions key in config/laracrate.php. ProcessFileAction resolves it from the container and merges it with the global steps before sorting by priority. Steps under models.{fileable_type}.actions are added (not substituted) for files whose fileable_type matches, so you can layer model-specific steps on top of collection-wide ones.
'collections' => [ 'documents' => [ // ... disk, access, etc. 'actions' => [ \App\Pipeline\Steps\ClassifyDocumentStep::class, // all documents ], 'models' => [ \App\Models\Lawsuit::class => [ 'actions' => [ \App\Pipeline\Steps\DetectDeadlinesStep::class, // lawsuits only ], ], ], ], ],
Per-collection action classes must implement FileActionInterface. If a configured class does not, ProcessFileAction logs a warning and skips it. For introspection you can call app(FileActionRegistry::class)->applicableFor($file) to see which global steps would run for a given file.
Images, variants and watermarks
Laracrate processes every image you upload into an optimized original plus any number of resized variants (thumbnails, medium, large, and so on). Variants are generated asynchronously in the pipeline, so the user's upload stays instant. Each variant is a real File row of its own with parent_id pointing at the original, type = image, and a variant name, so you can query and serve it like any other file. The original is never destroyed by variant generation, and the original is never watermarked.
You declare variants per file type inside a collection's config. The orchestrator (GenerateImageVariantsAction) iterates types.image.variants for the collection and dispatches GenerateImageVariantAction once per definition. A failure in one variant is logged and skipped, the rest still generate.
// config/laracrate.php 'collections' => [ 'gallery' => [ 'disk' => 'media', 'access' => 'public', 'types' => [ 'image' => [ 'variants' => [ 'thumbnail' => ['width' => 300, 'height' => 300, 'fit' => true], 'medium' => ['width' => 800, 'height' => 800], 'large' => ['width' => 1600, 'height' => 1600], ], ], ], ], ],
Where a variant lives
The variant binary is written under a variants/ subdirectory next to the original, using $file->variantKey($newName). The variant filename is the original base name plus the variant name, for example photo_thumbnail.webp. The variant row is created with $file->createVariant($name, $overrides), which inherits the parent's scope. Regeneration is idempotent: if a variant with the same name already exists it is force-deleted and rebuilt.
To read variant keys back, see the key, variantKey, and createVariant helpers covered in the Data model section, and use the model's variant accessors covered in the Working with files from your models section.
Variant options
Each variant definition accepts these keys. Per-variant values win, then the collection's types.image level, then the global defaults.image level, then a hardcoded fallback.
| Option | Type | Default | Meaning |
|---|---|---|---|
width |
int or null | null |
Target width. |
height |
int or null | null |
Target height. |
fit |
bool | false |
true crops to fill the box (cover). false scales down preserving aspect ratio without enlarging (scaleDown). |
quality |
int | 80 |
Encoder quality (0-100). |
format |
webp or jpg |
webp |
Output format. webp writes image/webp, jpg writes image/jpeg. |
watermark |
bool | false |
Bake the watermark into this variant. See below. |
Quality resolution is slightly special: a variant's own quality wins, otherwise types.image.variant_quality, then defaults.image.variant_quality (default 85), then the normal quality cascade.
Optimizing the original
The original is left untouched unless you opt in. OptimizeImageAction re-encodes a top-level image to WebP and downscales it to a maximum bounding box. It is a no-op for variants (variants are already generated optimized) and only runs when image.optimize_originals is true or the collection requests it.
// config/laracrate.php 'image' => [ 'driver' => 'imagick', // 'imagick' (recommended) or 'gd' 'optimize_originals' => false, 'max_width' => 1920, 'max_height' => 1920, 'quality' => 85, ],
The image.driver value selects the Intervention Image backend (imagick or gd) for all image work: optimization, variant generation, and watermarking. Image dimensions are captured separately by ExtractImageDimensionsAction and stored on width and height (it reads the binary with getimagesizefromstring and is a no-op once both are set).
Watermarks
A watermark is baked into the binary of specific variants at generation time. You opt in per variant with 'watermark' => true in that variant's definition. The original master file never carries a watermark, only the variants that explicitly ask for one.
// config/laracrate.php 'collections' => [ 'identity' => [ 'disk' => 'documents', 'access' => 'stream', 'types' => [ 'image' => [ 'variants' => [ 'thumbnail' => ['width' => 300], // no watermark 'display' => ['width' => 1200, 'watermark' => true], // watermarked ], ], ], ], ],
The watermark itself is configured once in the top-level watermark block. It can overlay a PNG, a text string, or both. If neither a PNG nor text is configured, applying the watermark is a no-op.
// config/laracrate.php 'watermark' => [ // PNG to overlay. Absolute path, or relative to public_path(). null skips the image. 'image_path' => env('LARACRATE_WATERMARK_IMAGE', null), // Width as a fraction of the variant width (0.0 - 1.0). 0.40 = 40% of the width. 'size' => 0.40, // PNG opacity (0-100). 'opacity' => 30, // 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'. 'position' => 'center', 'text' => [ // null = no text, a string = fixed text, or a closure(File): ?string for dynamic text. 'content' => null, // Font size as a fraction of the image width. 'font_size_ratio' => 0.0195, // CSS rgba color. 'color' => 'rgba(255, 255, 255, 0.60)', // 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right'. 'position' => 'bottom-left', // Padding from the edge, in pixels. 'padding' => 20, // Path to a .ttf font. null uses the system font. 'font_path' => null, ], ],
The text content closure receives the File and may return null to skip the text, which is useful for stamping per-file data (an owner id, an order number) onto the watermark. Because a closure cannot live in env, set it by publishing the config or in a service provider.
Video and PDF previews
For non-image files Laracrate can generate an image preview so you have something to render in a gallery or list. Both video and PDF previews work the same way: the package produces a child File with variant = 'preview' and type = image, then optionally generates its own image variants (thumbnail, medium, and so on) from that preview. Both run in the pipeline and depend on external binaries, so they are opt-in per collection.
Video
Video handling has two independent steps. Dimensions and duration are read with ffprobe (ExtractVideoDimensionsAction, stored on width, height, duration). Preview frame extraction (ExtractVideoPreviewAction) pulls a single frame with ffmpeg at the configured timestamp and writes it as a JPEG preview. Both require ffmpeg and ffprobe on the server path.
// config/laracrate.php 'collections' => [ 'gallery' => [ 'disk' => 'media', 'access' => 'public', 'types' => [ 'video' => [ 'preview' => [ 'frame_at' => '00:00:01', // timestamp of the frame to grab 'variants' => [ 'thumbnail' => ['width' => 300, 'height' => 300, 'fit' => true], 'medium' => ['width' => 800, 'height' => 800], ], ], ], ], ], ],
frame_at defaults to 00:00:01 when not set. The extracted frame becomes a preview child of type image, and the variants you list under preview are generated from it (each one is in turn a child of the preview), so a video ends up with a preview frame plus its own thumbnail and medium.
Transcoding is separate and costly, so enable it only on collections that need it. TranscodeVideoAction re-encodes the original to H.264 / AAC MP4 with ffmpeg, scaling it down to fit a bounding box, and replaces the original binary in place (then re-reads dimensions). It runs when the collection declares transcode => true.
// config/laracrate.php 'video' => [ 'max_width' => 1920, 'max_height' => 1920, 'bitrate_kbps' => 2500, ],
ExtractPdfPreviewAction rasterizes one page of a PDF (page 1 by default) to a PNG and stores it as a preview child of type image. As with video, any variants you declare under preview are generated from that PNG (a thumbnail and a medium of page 1).
// config/laracrate.php 'collections' => [ 'documents' => [ 'disk' => 'documents', 'access' => 'signed', 'types' => [ 'document' => [ 'preview' => [ 'page' => 1, 'width' => 2000, 'variants' => [ 'thumbnail' => ['width' => 300], 'medium' => ['width' => 800], ], ], ], ], ], ],
The rasterization engine is selectable with pdf_preview_engine (or a per-collection engine key inside the preview block):
| Engine | Requires | Notes |
|---|---|---|
pdftoppm |
poppler-utils (pdftoppm binary) |
No Ghostscript and no ImageMagick policy.xml changes. Downscaling falls back to GD. |
imagick |
PHP imagick extension, Ghostscript (gs), and PDF enabled in ImageMagick's policy.xml |
Reads the PDF through Ghostscript. |
auto |
either of the above | Tries pdftoppm first, falls back to imagick if it is not available. |
// config/laracrate.php 'pdf_preview_engine' => 'auto',
When the engine is forced to pdftoppm and the binary is missing or fails, the action logs and returns null rather than falling back. Set the engine per collection when one disk or document type needs a specific renderer:
'preview' => ['page' => 1, 'width' => 600, 'engine' => 'pdftoppm'],
Text extraction, embeddings and search (RAG)
Laracrate can turn your uploaded files into searchable text and vector embeddings, so you can run keyword, semantic, or hybrid search across everything a model owns. This is the foundation for retrieval-augmented generation (RAG): extract text from a PDF, contract, audio recording or scanned image, chunk it, embed it, store it, then query it. Everything runs in the processing pipeline (see the Processing pipeline section), so the user's upload stays instant.
The flow has four stages, each a separate action that hands off through portable sidecar files in storage:
ExtractText -> ChunkText -> GenerateEmbedding -> PersistChunks
| | | |
{key}.json {key}.chunks.jsonl (+embedding) ChunkStore
ExtractTextAction writes a {key}.json sidecar with the full text plus per-page segments. ChunkTextAction splits it into {key}.chunks.jsonl (one JSON object per line). GenerateEmbeddingAction rewrites that JSONL with an embedding field on each chunk. PersistChunksAction reads the final JSONL and stores it through the active ChunkStore driver. The JSONL is the canonical, portable artifact: if you switch backends later, you can rebuild every store by re-running PersistChunksAction over your files without re-extracting or re-embedding.
Enabling extraction and embeddings
There is a master switch and a per-collection opt-in. Both must be on for a file to be embedded.
First, flip the master switch in config/laracrate.php (it defaults to off, because most uploads do not need this):
'embeddings' => [ 'enabled' => true, // ... ],
Then opt in per collection with extract and embed:
'collections' => [ 'documents' => [ 'disk' => 'documents', 'access' => 'signed', 'extract' => true, // run the extractor chain, write {key}.json 'embed' => true, // chunk + embed + persist to the ChunkStore ], ],
extract alone gives you searchable plain text and the {key}.json sidecar (readable via $file->extractedText(), see the end of this section) without any embedding API cost. Add embed to generate vectors and enable semantic search.
The extractor chain and fallback
Extraction runs through a TextExtractorRegistry: an ordered list of extractors. For a given file, the registry collects every extractor whose supports($file) returns true (chainFor($file)), then ExtractTextAction tries them in order. If an extractor returns fewer characters than embeddings.min_text_per_file (default 100), the action keeps the best result so far and tries the next one. The first extractor to clear the threshold wins. This is how a native PDF reader can fall back to OCR for a scanned PDF.
The bundled extractors:
| Extractor | Handles | Engine | Paid API |
|---|---|---|---|
PdfTextExtractor |
application/pdf (native, text-based) |
smalot/pdfparser, per page |
No |
OcrPdfTextExtractor |
PDF (scanned, no extractable text) | Anthropic or OpenAI, PDF sent as base64 | Yes |
PlainTextExtractor |
text/*, csv, json, xml, html, markdown |
reads bytes directly | No |
OcrImageTextExtractor |
jpeg, png, webp, gif, heic, heif | Vision (Anthropic or OpenAI) | Yes |
AudioTranscribeExtractor |
audio/* |
OpenAI Whisper | Yes |
VideoTranscribeExtractor |
video/* |
ffmpeg, then Whisper (visual frames optional) | Yes |
All class names live under EduLazaro\Laracrate\Extractors\. The OCR, audio and video extractors call paid third-party APIs and incur per-file cost (the source documents rough estimates, for example a 10-page PDF OCR is around 0.004 USD on Claude Haiku, audio transcription is around 0.006 USD per minute). They also fail safe: if no API key is configured for the selected provider, supports() returns false and the chain moves to the next extractor instead of erroring out.
OcrImageTextExtractor and VideoTranscribeExtractor emit two segments with distinct context values, text (verbatim OCR or transcript) and description (a visual summary), so the chunker produces a separate embedding for each. Video visual frame description is opt-in: add the pseudo-type 'video.visual' to the collection's extract array to enable it (LARACRATE_VIDEO_FRAME_INTERVAL controls the seconds between frames, default 30).
Registering extractors
By default (when embeddings.extractors is empty) the registry loads only PdfTextExtractor then PlainTextExtractor. To control the order and which extractors run, set embeddings.extractors to a list of fully qualified class names. A recommended chain for legal or scanned documents:
'embeddings' => [ 'enabled' => true, 'extractors' => [ \EduLazaro\Laracrate\Extractors\PdfTextExtractor::class, // native PDFs, free \EduLazaro\Laracrate\Extractors\OcrPdfTextExtractor::class, // scanned PDFs, paid OCR \EduLazaro\Laracrate\Extractors\OcrImageTextExtractor::class, \EduLazaro\Laracrate\Extractors\PlainTextExtractor::class, ], ],
Order matters: put the fast, free extractor first and the paid OCR fallback second, so OCR only runs when the cheap path returns too little text.
To register a custom extractor at runtime instead, resolve the registry and add an instance (for example in a service provider's boot()):
use EduLazaro\Laracrate\Support\TextExtractorRegistry; app(TextExtractorRegistry::class) ->add(new \EduLazaro\Laracrate\Extractors\PdfTextExtractor()) ->add(new \App\Extractors\MyOcrExtractor());
A custom extractor implements EduLazaro\Laracrate\Contracts\TextExtractor with two methods: supports(File $file): bool and extract(File $file): ExtractedContent. Build the return value with ExtractedContent::singlePage($text, $metadata) or ExtractedContent::fromPages($pages, $metadata), where each page is ['page_number' => int, 'text' => string] (and optionally 'context' => string).
OCR configuration
The OCR extractors pick their provider from ocr.provider (anthropic by default, or openai):
LARACRATE_OCR_PROVIDER=anthropic LARACRATE_OCR_MODEL=claude-haiku-4-5 LARACRATE_ANTHROPIC_API_KEY=sk-ant-... # or for OpenAI: # LARACRATE_OCR_PROVIDER=openai # LARACRATE_OPENAI_API_KEY=sk-...
API key resolution order, per provider, is: the value passed to the extractor constructor, then config('laracrate.ocr.{provider}.api_key'), then LARACRATE_{PROVIDER}_API_KEY, then the generic ANTHROPIC_API_KEY / OPENAI_API_KEY. Model resolution is: constructor argument, then config('laracrate.ocr.{provider}.model'), then LARACRATE_OCR_MODEL, then the provider default (claude-haiku-4-5 for Anthropic, gpt-4o-mini for OpenAI). Audio transcription is OpenAI only (Whisper) and reads LARACRATE_OPENAI_API_KEY with model LARACRATE_AUDIO_MODEL (default whisper-1).
Chunking knobs
ChunkTextAction splits the extracted text into overlapping chunks. The token figures are approximate (the chunker counts roughly four characters per token):
| Config key | Default | Meaning |
|---|---|---|
embeddings.chunk_size |
1000 |
Approx tokens per chunk. 0 means one chunk per file (no splitting). |
embeddings.chunk_overlap |
100 |
Approx tokens shared between consecutive chunks. |
embeddings.batch_size |
16 |
Chunks per request when calling the embedding provider. |
If any extracted page carries a context, the chunker splits per page and propagates the context to each chunk; otherwise it concatenates all pages and tracks which source page each chunk spans.
Embedding providers
The embedding provider is resolved from the EduLazaro\Laracrate\Contracts\EmbeddingProvider binding. The contract is embed(array $texts): array (a vector per input string, same order), plus dimensions(), model() and name().
| Provider | When | Notes |
|---|---|---|
OpenAiEmbeddingProvider |
default (embeddings.provider = 'openai') |
model text-embedding-3-small, 1536 dimensions. Reads laracrate.embeddings.api_key or OPENAI_API_KEY. |
NullEmbeddingProvider |
tests, or provider = 'null' |
throws on embed() so misconfiguration is loud instead of silent. |
Both live under EduLazaro\Laracrate\Embeddings\. To use your own provider (a self-hosted model, Anthropic, BGE-M3, etc.), bind it in your service provider:
use EduLazaro\Laracrate\Contracts\EmbeddingProvider; $this->app->bind(EmbeddingProvider::class, \App\Embeddings\MyProvider::class);
Note the query side: when you search semantically, the active ChunkStore embeds the query with this same provider, so the query vectors and the stored chunk vectors must come from the same model.
Chunk stores
A ChunkStore is the persistence and search backend. The contract (EduLazaro\Laracrate\Contracts\ChunkStore) has five methods:
public function store(File $file, array $chunks): int; public function getByFile(File $file): \Illuminate\Support\Collection; public function search(string $query, array $filters = [], array $options = []): \Illuminate\Support\Collection; public function deleteByFile(File $file): void; public function driverName(): string;
The driver is selected by chunks.driver (default mysql):
| Driver | Class | Search | Requirements | Scale |
|---|---|---|---|---|
mysql |
MysqlChunkStore |
keyword LIKE + cosine similarity computed in PHP |
none | good up to roughly 5K chunks per scope |
meilisearch |
MeilisearchChunkStore |
native server-side hybrid (BM25 + vector) with semanticRatio |
meilisearch/meilisearch-php + a bound Meilisearch\Client |
large |
Both classes are under EduLazaro\Laracrate\Chunks\. With the mysql driver, chunks are rows in laracrate_file_chunks (the text column has a FULLTEXT index, though keyword matching currently uses SQL LIKE) and the embedding is stored alongside; search pulls a candidate pool and ranks it in PHP. With meilisearch, chunks are pushed as documents into the configured index with embeddings injected as _vectors.{embedder} (userProvided mode), and laracrate_file_chunks is not written: Meilisearch becomes the single source of chunks while the .chunks.jsonl sidecar stays the portable backup.
Wiring up Meilisearch
Add the env config:
LARACRATE_CHUNKS_DRIVER=meilisearch LARACRATE_MEILISEARCH_INDEX=laracrate_file_chunks LARACRATE_MEILISEARCH_EMBEDDER=default
Then bind a Meilisearch\Client in your AppServiceProvider (Laracrate resolves it from the container and falls back to MysqlChunkStore with a warning if it is not bound):
use Meilisearch\Client; public function register(): void { $this->app->singleton(Client::class, fn () => new Client( config('services.meilisearch.host', 'http://127.0.0.1:7700'), config('services.meilisearch.key'), )); }
The index is created and configured on demand: MeilisearchSync::ensureIndex() sets the filterable, sortable and searchable attributes and registers the embedder as userProvided with your configured dimensions.
A custom store (Qdrant, pgvector, ...)
Implement the five-method contract and bind it directly. Your binding wins over the built-in driver switch:
use EduLazaro\Laracrate\Contracts\ChunkStore; $this->app->singleton(ChunkStore::class, \App\Chunks\QdrantChunkStore::class);
Searching
Run a search through the active ChunkStore. The signature is search(string $query, array $filters = [], array $options = []) and it returns a Collection of result rows.
use EduLazaro\Laracrate\Contracts\ChunkStore; $results = app(ChunkStore::class)->search('termination clause', [ 'fileable_type' => \App\Models\Matter::class, 'fileable_id' => $matter->id, 'tenant_id' => $org->id, ], [ 'limit' => 10, 'semantic_ratio' => 0.7, ]); foreach ($results as $hit) { // $hit['file_id'], $hit['chunk_index'], $hit['text'], // $hit['score'], $hit['matched'] ('keyword' | 'semantic' | 'hybrid'), // $hit['metadata'] }
Supported filter keys: file_ids (array of ints), fileable_type, fileable_id, tenant_type, tenant_id, collection, context, category. Options are limit (default 10) and semantic_ratio (a float from 0 to 1, default 0.7).
semantic_ratio is the cost and quality dial:
0: keyword only. The query is not embedded, so there is no embedding API call. Use this for cheap, exact-match search or when embeddings are off.>0: the query is embedded with yourEmbeddingProviderand blended with keyword results.1.0is pure semantic. Anything in between is hybrid.
To read the extracted plain text of a single file (no search backend involved), use the model accessor. extractedText() returns the full text from the {key}.json sidecar, or null if extraction has not run:
$text = $file->extractedText(); // full concatenated text, or null $content = $file->extractedContent(); // ExtractedContent DTO: fullText, pages[], metadata
extractedContent() returns an ExtractedContent object whose pages array preserves per-page (or per-segment) text, useful for citing a source page back to the user.
Access control and authorization
Every collection declares an access mode that controls how its files reach the browser. The mode lives in the collection config (see the Configuration section) and is stored on each file as the access column, backed by the EduLazaro\Laracrate\Enums\FileAccess enum. When you call $file->url(), Laracrate resolves the right strategy for you, you never build storage URLs by hand.
The three modes are:
| Mode | Enum case | How url() resolves it |
Audit | Use for |
|---|---|---|---|---|
public |
FileAccess::PUBLIC |
Storage::disk()->url(), direct CDN link, no signature |
No | Avatars, logos, public marketing assets |
signed |
FileAccess::SIGNED |
Storage::disk()->temporaryUrl(), short-lived presigned GET, cached server-side |
No | Private-ish files where a temporary direct link is fine |
stream |
FileAccess::STREAM |
Signed Laravel route to the package stream controller, the binary is proxied through your app | Yes | Sensitive content, anything that needs per-request checks |
$file->url() delegates to StorageManager::urlFor(), which switches on the access value: public calls GeneratePublicUrlAction, stream calls GenerateSensitiveStreamUrlAction, and everything else falls back to GenerateSignedUrlAction. Signed URLs are cached server-side (config laracrate.urls.signed_cache_ttl, default 4 minutes) so a page rendering many files does not issue one presign per file. If a disk is misconfigured or unreachable, the signed action logs a warning and returns null instead of throwing, so one broken file does not break the whole page.
Declaring authorization rules
Authorization is declared in the PolicyRegistry (EduLazaro\Laracrate\Support\PolicyRegistry), keyed by the fileable morph alias (user, case, property, and so on). Register your closures from a service provider boot() method:
use EduLazaro\Laracrate\Support\PolicyRegistry; use EduLazaro\Laracrate\Models\File; use Illuminate\Database\Eloquent\Model; public function boot(): void { app(PolicyRegistry::class) ->viewable('case', fn (File $file, ?Model $user) => $user?->canAccessCase($file->fileable)) ->editable('case', fn (File $file, ?Model $user) => $user?->isLawyer()) ->deletable('case', fn (File $file, ?Model $user) => $user?->isAdmin()); }
Each registrar (viewable, editable, deletable) returns $this, so calls chain. The closure receives the File and the current user (which may be null for guests) and returns a boolean.
At decision time, three methods evaluate the rules: canView(), canEdit(), and canDelete(). They are exposed on the registry and mirrored on the model, so $file->canView($user), $file->canEdit($user), and $file->canDelete($user) all work in blade and controllers.
Default policy: deny with exceptions
If no closure is registered for a fileable type, Laracrate applies safe defaults driven by the registry logic:
- The human creator can always view, edit, and delete their own file. A file counts as creator-owned when
creator_typeisuserandcreator_idmatches the current user's key. - Public files are always viewable by anyone (
canViewreturnstruewhenaccessispublic), regardless of registered closures. - For everything else, when no closure exists the answer is
false. The system is closed by default: you opt files in, you never accidentally leak them.
So a registered viewable closure only runs for non-creators viewing a non-public file. Edit and delete have no public exception, only the creator shortcut and your closures.
The Gate bridge
By default the package binds EduLazaro\Laracrate\Policies\FilePolicy to Laravel's Gate, so you get native ergonomics on top of the registry. This is controlled by laracrate.policies.register_gate (default true):
@can('view', $file) <a href="{{ $file->url() }}">Open</a> @endcan
// In a controller $this->authorize('update', $file); // As route middleware Route::get('/files/{file}', ...)->middleware('can:view,file');
The bridge maps Laravel's canonical abilities to the registry methods: Gate view calls canView, update calls canEdit, and delete calls canDelete. If your app already registers its own File policy, or you do not want the bridge, set register_gate to false and call $file->canView() (and friends) directly.
Sensitive content and encryption
For files that must never be served by a direct storage URL, set the collection's access mode to stream and mark it sensitive. Streamed files are proxied through EduLazaro\Laracrate\Http\Controllers\StreamFileController, which re-checks permissions on every request, audits the access, and (when the collection is encrypted) decrypts the binary in memory before sending it. The storage backend URL is never exposed to the client.
The per-request stream flow
When access is stream, $file->url() returns a temporary signed route built by GenerateSensitiveStreamUrlAction. The controller exposes stream, preview, download, and link actions, each routed under the laracrate.files.* names (laracrate.files.stream, laracrate.files.preview, laracrate.files.download). Every request runs through validateAccess() before a single byte is sent:
- Signature check. The route must carry a valid Laravel signature (
hasValidSignature()), otherwise the controller aborts403. URLs are signed with a TTL fromlaracrate.urls.route_signed_ttl(default 15 minutes). - Viewer bind (sensitive only). If
$file->isSensitive()andlaracrate.urls.bind_to_useris on (defaulttrue), the request must be authenticated, and theuquery parameter (the user id baked into the URL when it was generated) must match the currentAuth::id(). A leaked URL pasted into another session aborts403. Generation only addsuwhen a user is logged in, seeGenerateSensitiveStreamUrlAction. - Policy check.
$file->canView($request->user())runs the same PolicyRegistry logic described in the Access control and authorization section. Failure aborts403.
Only after all three pass does the controller serve the file. The signed route in your HTML is what makes sensitive links safe to render: even if the page is cached, the link expires on its own and is bound to the viewer.
Audit and download tracking
sendFile() audits before streaming. When the request is a stream or download (not preview) and laracrate.stream.increment_downloads is on (default true), the file's downloads_count is incremented and last_downloaded_at is stamped with a quiet save (no events fired). When laracrate.stream.log_access is on (default true), an info log records the file id, collection, user id, IP, and method. Streamed responses are sent with Cache-Control: private, no-store, no-cache, must-revalidate so sensitive bytes are never cached downstream.
Encryption at rest
Set encrypt => true on a collection to encrypt the binary before it is written to the backend. CreateFileAction reads the flag, runs EncryptFileAction on the binary, and persists is_encrypted = true on the file. Encryption uses Laravel's app key:
EncryptFileActiondoesCrypt::encryptString(base64_encode($binary)).DecryptFileActionreads the cipher from the backend and returnsbase64_decode(Crypt::decryptString($cipher)).
Encryption requires the binary to be present server-side. If a collection has encrypt => true but the upload skipped your server (a direct presigned upload, for example), CreateFileAction throws, because there is nothing for PHP to encrypt. Encrypted collections must route the bytes through your app on the way in.
On the way out, the stream controller transparently decrypts: in sendFile(), when $file->is_encrypted is true it calls DecryptFileAction::create()->run(['file' => $file]), otherwise it reads the raw object. Encryption pairs naturally with access => stream, since the binary has to pass through your app to be decrypted anyway.
Watermarks are baked in, not applied at stream time
Watermarking is not part of the stream flow. The watermark is rendered into the binary of the relevant variant when that variant is generated by the processing pipeline (see the Images, variants and watermarks section), not on each request. The stream controller only validates, decrypts, and serves: it never re-renders pixels. This keeps streaming cheap and makes the watermarked bytes the only bytes that exist for that variant.
Multi-tenancy, buckets and usage
Laracrate is multi-tenant aware out of the box. Every file carries a tenant_* morph (see the Data model section), so a single deployment can serve many organizations, workspaces, or accounts while keeping their files cleanly partitioned. On top of that scope you can give individual tenants their own storage bucket and measure exactly how much each one consumes, without writing a single hand-rolled SUM() query.
If you run a single-tenant app, leave tenant_* null and skip this section. Everything below is opt-in.
Scoping files to a tenant
The tenant morph is set automatically when you add a file through your model. How the tenant is resolved is up to you (it comes from resolveFileTenant() on the HasFiles trait, covered in the Working with files from your models section). Once files are scoped, you can query them with the forTenant scope on the File model:
use EduLazaro\Laracrate\Models\File; $files = File::forTenant($organization)->get();
The scope matches on tenant_type (the tenant's morph class) and tenant_id (its primary key).
Per-tenant buckets
By default all tenants share the disks you declare in config/filesystems.php. A tenant bucket overrides one of those disks for one tenant. This is the foundation for bring-your-own-account (BYOA) setups, data-residency or compliance requirements, and cost attribution, where a tenant's bytes land in a bucket you can bill or audit separately.
Granularity is per disk, not per collection. If your config exposes three disks (document, media, attachment), a tenant can activate a dedicated bucket for any subset of them independently. The disk you override is called the base_disk.
Declare a tenant bucket by creating a TenantBucket row:
use EduLazaro\Laracrate\Models\TenantBucket; TenantBucket::create([ 'tenant_type' => $organization->getMorphClass(), 'tenant_id' => $organization->getKey(), 'base_disk' => 'document', // the disk in filesystems.php this overrides 'bucket' => 'acme-documents', // bucket name that replaces base_disk's 'public_url' => 'https://cdn.acme.example', // optional, overrides the base disk url 'is_active' => true, 'label' => 'acme-documents (eu-west)', // optional, for your admin UI ]);
TenantBucket is stored in the laracrate_tenant_buckets table and exposes a tenant() morph relation back to the owning model. Its columns:
| Column | Type | Purpose |
|---|---|---|
tenant_type, tenant_id |
string, bigint | The tenant this bucket belongs to. |
base_disk |
string | The config/filesystems.php disk this row overrides. |
bucket |
string | Bucket name that replaces the one from base_disk. |
public_url |
string, nullable | Overrides the url of the base disk if set. |
credentials |
encrypted array, nullable | BYOA overrides for key, secret, endpoint, region, driver. Cast encrypted:array (APP_KEY). |
is_active |
boolean | Inactive buckets are not resolved and throw if referenced. |
label |
string, nullable | Human label for your admin UI. |
There is a unique constraint on (tenant_type, tenant_id, base_disk), so a tenant has at most one bucket per base disk.
Two deployment models are supported:
- SaaS, single account. Your R2/S3 credentials live in
.envand the base disk config. Each tenant bucket only setsbucket(and optionallypublic_url); everything else (key, secret, endpoint, region, driver) is inherited frombase_disk. - BYOA, tenant brings their own account. Put the tenant's
key,secret,endpoint,region, anddriverincredentials. It is stored encrypted with your APP_KEY and merged on top of the inherited config.
How a file picks up its bucket
When you add a file, the HasFiles trait calls resolveTenantBucketDisk(). If the resolved tenant has an active TenantBucket for the collection's base_disk, the file is stored with disk = "tb:{id}" (the bucket's id), otherwise it keeps the plain disk name. You never write tb:{id} by hand.
At read or write time, StorageManager::diskFor() (and the lower-level resolveDisk()) recognizes the tb: prefix, loads the TenantBucket, builds the config via TenantBucket::toDiskConfig(), and returns the right Storage::build() filesystem. The merge cascade is: base disk config, then bucket (and public_url as url), then the BYOA credentials overrides. Plain disk names fall straight through to Storage::disk(). Every internal operation (writeBinary, moveServerSide, batchDelete, presigned uploads, S3 client lookup) routes through this resolution, so dedicated buckets work everywhere transparently.
If a file references a bucket that no longer exists or has is_active = false, resolution throws a RuntimeException rather than silently falling back to the shared disk.
Usage accounting
To enforce quotas or bill by storage, use UsageReporter, resolved from the container. Each query is a single grouped SUM(size) / COUNT(*), so you get totals plus per-collection and per-type breakdowns without scanning rows in PHP.
use EduLazaro\Laracrate\Services\UsageReporter; $stats = app(UsageReporter::class)->forTenant($organization); $stats->human(); // "1.42 GB" $stats->totalFiles; // 312 $stats->byCollection['gallery']; // ['bytes' => 18234112, 'files' => 45] if ($stats->exceeds(5 * 1024 ** 3)) { // tenant is over its 5 GB quota }
UsageReporter has four entry points, each returning a UsageStats:
| Method | Scope |
|---|---|
forTenant(Model $tenant, bool $excludeTrashed = false) |
All files belonging to a tenant. |
forCreator(Model $creator, bool $excludeTrashed = false) |
All files created by a given model. |
forCollection(string $collection, ?Model $tenant = null, bool $excludeTrashed = false) |
One collection, optionally narrowed to a tenant. |
global(bool $excludeTrashed = false) |
Whole system. This can be a heavy scan on large tables, so run it offline or behind a cache. |
By default counts include variants and soft-deleted files, since both still occupy real bytes in the bucket. Pass excludeTrashed: true to drop soft-deleted files from the totals.
The UsageStats value object
UsageStats (EduLazaro\Laracrate\Support\UsageStats) is an immutable snapshot. Its readonly properties:
| Property | Type | Notes |
|---|---|---|
totalBytes |
int | Sum of size across the scope. |
totalFiles |
int | File count across the scope. |
byCollection |
array | ['gallery' => ['bytes' => int, 'files' => int], ...] |
byType |
array | ['image' => ['bytes' => int, 'files' => int], ...] |
And its methods:
| Method | Returns |
|---|---|
kilobytes() / megabytes() / gigabytes() |
float, total in that unit |
human(int $precision = 2) |
string like "1.42 GB", "234 MB", "12 KB" |
exceeds(int $quotaBytes) |
bool, true when over the quota |
remaining(int $quotaBytes) |
int bytes left, negative if over |
percentageOf(int $quotaBytes) |
float, 0 to 100+ |
toArray() |
array with total_bytes, total_files, by_collection, by_type |
Store your per-tenant limit however you like (a quota_bytes column on your tenant model is a common choice) and gate uploads with exceeds():
$stats = app(UsageReporter::class)->forTenant($organization); abort_if( $stats->exceeds($organization->quota_bytes), 403, 'Storage quota exceeded.' );
Cached usage counters and recompute
For collections where you want a live counter without aggregating on every request, Laracrate can maintain per-fileable totals (file count and byte size) in the laracrate_folderables table, kept up to date by the file observer. To enable this, set track_usage on the collection in config/laracrate.php.
Because observers can miss writes (manual imports, restores from backup, a failed event), the laracrate:recompute-usage command rebuilds those counters from the source of truth in laracrate_files. It is idempotent and safe to run on a schedule.
php artisan laracrate:recompute-usage # every collection with track_usage enabled php artisan laracrate:recompute-usage drive # only the "drive" collection php artisan laracrate:recompute-usage --dry-run # print deltas without writing
It aggregates top-level files (no parent_id) per (fileable_type, fileable_id), updates each laracrate_folderables row, stamps last_recomputed_at, and resets orphaned rows (files all gone) to zero. See the Artisan commands section for the full command list.
Livewire components and themes
Laracrate ships an optional Livewire UI layer. The core (models, storage, pipeline, HTTP endpoints) works without Livewire, so you can ignore this section entirely and build your own front end against the methods in the "Working with files from your models" section. If you do use Livewire, these components wire the model trait, collection config, and upload flow together for you.
The components register only when Livewire is installed (registerLivewireComponents() no-ops otherwise). Each one resolves a Blade theme at render time, so the look is fully swappable.
Minimal example: a single-file uploader for a user avatar.
<livewire:laracrate-uploader :model="$user" collection="avatar" />
The six components
All six are registered in LaracrateServiceProvider with the tag names below. They split into two families: card uploaders (single file, in-place preview) and dropzones (presigned direct upload to R2/S3, see the "Upload modes" and "HTTP endpoints" sections). Each family has an instant variant and a deferred variant. Deferred means selecting a file stages it for review and only persists when the user confirms.
| Tag | Class | Files | Behavior | Use case |
|---|---|---|---|---|
laracrate-uploader |
LaracrateUploader |
single | Instant: selecting a file calls setFile() immediately |
Avatar, logo, cover image |
laracrate-uploader-deferred |
LaracrateUploaderDeferred |
single | Deferred: preview, then submit() to persist or cancel() to discard |
Same as above when you want an explicit confirm step |
laracrate-dropzone |
LaracrateDropzone |
multiple | Instant direct upload per file, fires per-file and batch events | Galleries, document libraries |
laracrate-dropzone-deferred |
LaracrateDropzoneDeferred |
multiple | Deferred: queue files, review, then upload the whole batch. Supports file slots and an integrated slot picker | Forms where files are tied to slots or categories |
laracrate-dropzone-single |
LaracrateDropzoneSingle |
single | Instant direct upload, one file, in-place preview | One large file (a video, a PDF) too big for the card uploader |
laracrate-dropzone-single-deferred |
LaracrateDropzoneSingleDeferred |
single | Deferred single direct upload, in-place preview | Same, with an explicit confirm step |
The card uploaders (laracrate-uploader, laracrate-uploader-deferred) push the file through Livewire's temporary upload, so the binary passes through PHP. The dropzones upload straight to your disk with a presigned PUT and the binary never touches PHP. Pick the dropzone variants for large files.
Props
Both card uploaders accept the same props:
| Prop | Type | Default | Purpose |
|---|---|---|---|
model |
Eloquent model | required | The model the file belongs to (must use the HasFiles trait) |
collection |
string | required | Collection name as declared on the model |
variant |
string | null |
Variant to show in the preview (falls back to the master, then the collection placeholder) |
theme |
string | null |
Theme name (falls back to laracrate.ui.default_theme) |
layout |
string | row |
row or portrait |
rounded |
string | null |
Override preview rounding: none, sm, md, lg, xl, 2xl, 3xl, full |
The dropzone components share model, collection, and theme, plus their own props. Common ones across the dropzones: contextKey (an opaque identifier echoed back in the laracrate-file-uploaded event so you can route a file when several widgets share a page), folderId (target folder, see the "Folders" section), persistQueue, and maxFiles. LaracrateDropzoneDeferred adds slot support (slots, slotOptions, slotLabel, slotPlaceholder, slotOptional, selectedSlotId), a creator override (creator, or creatorType plus creatorId), hideActions, and a layout of grid or list. The single dropzones add hideExisting; the single deferred adds hideActions. See the "File slots" section for slot semantics.
Immediate vs deferred
Instant uploader, persists on selection:
<livewire:laracrate-uploader :model="$user" collection="avatar" theme="ios" variant="medium" />
Deferred uploader, shows a preview and waits for the user to confirm:
<livewire:laracrate-uploader-deferred :model="$user" collection="avatar" theme="studio" layout="portrait" />
A deferred multi-file dropzone, queue then batch upload:
<livewire:laracrate-dropzone-deferred :model="$organization" collection="gallery" theme="studio" />
All components dispatch browser events you can listen for: laracrate-file-uploaded (per file, with collection, fileId, and contextKey), laracrate-file-deleted or laracrate-file-removed, and on the dropzones laracrate-batch-completed (with ok and error counts). The deferred dropzone also dispatches laracrate-file-rejected when a file is refused by the collection or a slot quota. See the "Events" section for the server-side events fired during processing.
Themes and layouts
There are 11 themes: default, brutalist, material, ios, glassmorphism, neon, minimal, neumorphism, chatgpt, claude, and studio. Each card-uploader theme ships in 2 layouts, row and portrait, as resources/views/uploader/themes/{theme}/{row,portrait}.blade.php. The dropzone families resolve a single Blade per theme (for example dropzone/themes/{theme}.blade.php) and fall back to default when a theme has no matching view.
Set the project-wide default in config (the theme prop overrides it per component):
// config/laracrate.php 'ui' => [ 'default_theme' => env('LARACRATE_THEME', 'default'), ],
LARACRATE_THEME=studio
Customizing views
Publish the package views to override any theme or write a new one:
php artisan vendor:publish --tag=laracrate-views
The views land in resources/views/vendor/laracrate/. Edit a theme in place, or add a new theme by creating uploader/themes/{name}/row.blade.php and uploader/themes/{name}/portrait.blade.php (create matching files under dropzone/themes/, dropzone-single/themes/, etc. if you want the new theme available to those components too). Run php artisan view:clear after editing Blade files. The view contract (the variables each theme receives, such as $config, $file, $state, $previewUrl, $acceptAttr, $maxSizeKb, $pollMs, $roundedClass) is the same for every theme; copy an existing theme as your starting point.
Localization
The UI strings used by the Livewire components are translatable through the laracrate translation namespace, loaded by the service provider via loadTranslationsFrom(__DIR__ . '/../lang', 'laracrate'). The package ships English (lang/en/uploader.php) and Spanish (lang/es/uploader.php) out of the box, and resolves strings against your app's active locale.
Reference a string with the namespaced key:
{{ __('laracrate::uploader.upload') }}
The strings live in the uploader group. A sample of the keys (see the source files for the full list):
| Key | English | Spanish |
|---|---|---|
upload |
Upload file | Subir archivo |
select |
Select file | Seleccionar archivo |
replace |
Replace | Reemplazar |
delete |
Delete | Eliminar |
delete_confirm |
Delete this file? | ¿Borrar este archivo? |
submit |
Upload | Subir |
cancel |
Cancel | Cancelar |
uploading |
Uploading... | Subiendo... |
processing |
Processing | Procesando |
failed |
Error | Error |
slot_placeholder |
Unclassified | Sin clasificar |
max_size |
max :size MB | máx :size MB |
Keys like max_size and max_size_capital take a :size placeholder.
Overriding strings
Publish the translation files into your app, then edit them:
php artisan vendor:publish --tag=laracrate-translations
The files land in lang/vendor/laracrate/{locale}/uploader.php. Any key you define there overrides the package default for that locale; keys you leave out fall back to the package.
Adding a locale
Create lang/vendor/laracrate/{locale}/uploader.php for the new locale (copy en/uploader.php and translate the values, keeping the keys), then set your app locale. For example, to add French, add lang/vendor/laracrate/fr/uploader.php and the components will pick it up when app()->getLocale() returns fr.
Artisan commands
Laracrate ships three commands for the maintenance work that keeps storage costs and usage counters honest. None of them run on their own: register the ones you need in your scheduler.
use Illuminate\Support\Facades\Schedule; Schedule::command('laracrate:abort-stale-multipart')->hourly(); Schedule::command('laracrate:purge-expired')->hourly(); Schedule::command('laracrate:recompute-usage')->daily();
laracrate:abort-stale-multipart
Aborts multipart upload sessions that passed their expires_at without completing. This is the important one for production: until a multipart session is aborted, the parts already uploaded to S3/R2 keep occupying storage and billing you indefinitely. The command loads stale sessions via the MultipartUpload::stale() scope and runs AbortMultipartUploadAction on each, marking them MultipartUploadStatus::EXPIRED.
php artisan laracrate:abort-stale-multipart php artisan laracrate:abort-stale-multipart --dry-run --limit=200
| Option | Default | Effect |
|---|---|---|
--dry-run |
off | List the stale sessions, abort nothing. |
--limit= |
500 |
Maximum sessions to process per run. |
See the Multipart uploads coverage in the Upload modes section for how these sessions are created.
laracrate:purge-expired
Deletes files from collections that declare a TTL and have outlived it. A collection opts in by setting ttl_hours in its config/laracrate.php entry. The command walks every configured collection, computes a cutoff of now()->subHours($ttlHours), and force-deletes top-level files (parent_id null) created before it. It uses forceDelete() on purpose so the FileObserver purges the binary and its sidecars from the backend and cascades to variants.
php artisan laracrate:purge-expired php artisan laracrate:purge-expired --collection=temp_uploads --dry-run
| Option | Default | Effect |
|---|---|---|
--collection= |
all | Restrict to one collection. |
--dry-run |
off | List expired files, delete nothing. |
--limit= |
1000 |
Maximum files to process per collection. |
Collections without a positive ttl_hours are skipped, so this command is safe to schedule even if only some collections are temporary. See the Configuration section for ttl_hours.
laracrate:recompute-usage
Recomputes the laracrate_folderables usage counters (files_count, total_size_bytes) from laracrate_files. The per-row counters are kept up to date by the FileObserver, but they can drift if the observer fails, you import rows by hand, or you restore from a backup. This command re-aggregates the truth, updates last_recomputed_at, and resets orphaned rows to 0/0. It is idempotent, so running it repeatedly always converges to the correct state.
It only touches collections with track_usage enabled in config, unless you name one explicitly.
php artisan laracrate:recompute-usage php artisan laracrate:recompute-usage drive php artisan laracrate:recompute-usage --dry-run
| Argument / Option | Default | Effect |
|---|---|---|
collection (argument) |
all tracked | Restrict to one collection. |
--dry-run |
off | Print the per-row deltas without persisting. |
See the Multi-tenancy, buckets and usage section for how track_usage and the usage counters work.
Events
Laracrate fires plain Laravel events around the processing pipeline so your app can react without coupling to the internals: refresh UI, invalidate caches, notify users, or index vectors into your search engine. Every event uses the Illuminate\Foundation\Events\Dispatchable trait, lives under the EduLazaro\Laracrate\Events namespace, and exposes its payload as public constructor-promoted properties.
use EduLazaro\Laracrate\Events\FileProcessed; use Illuminate\Support\Facades\Event; Event::listen(function (FileProcessed $event) { $event->file->fileable->notify(new FileReady($event->file)); });
| Event | When it fires | Payload |
|---|---|---|
FileProcessingStarted |
Just before the pipeline iterates its steps. The file is already marked ProcessingStatus::PROCESSING. |
File $file |
FileProcessed |
The pipeline finished successfully. The file is already marked ProcessingStatus::COMPLETED. |
File $file |
FileProcessingFailed |
A step threw. The file is marked ProcessingStatus::FAILED and processing_error holds the message. The queue retries if the job has tries left. |
File $file, Throwable $exception |
VariantGenerated |
A child file (parent_id set) is persisted: thumbnail, preview, transcoded copy, watermarked variant, and so on. Fired centrally from the observer, independent of which action created it. |
File $variant, ?File $parent |
EmbeddingsReady |
The embedding step finished and produced at least one new vector. | File $file, int $count (chunks that ended up with an embedding in this pass) |
A common use of EmbeddingsReady is to mirror the generated vectors into your search backend (pgvector, Meilisearch, Qdrant) from a queued listener:
namespace App\Listeners; use EduLazaro\Laracrate\Events\EmbeddingsReady; use Illuminate\Contracts\Queue\ShouldQueue; class IndexFileVectors implements ShouldQueue { public function handle(EmbeddingsReady $event): void { // $event->count chunks now carry an embedding MyVectorIndex::sync($event->file); } }
Register listeners the usual way (an Event::listen call in a service provider, an auto-discovered handle method, or a #[AsEventListener] attribute). See the Processing pipeline section for what runs between FileProcessingStarted and FileProcessed, and the Text extraction, embeddings and search (RAG) section for the embedding step behind EmbeddingsReady.
API reference
A compact, signature-level reference for the public surface of Laracrate. Every entry below is copied from source. For narrative usage of each piece, see the section it belongs to (working with files from your models, displaying files, processing pipeline, and so on).
HasFiles trait
Add use EduLazaro\Laracrate\Concerns\HasFiles; to any model. See the Working with files from your models section for usage.
// Relations and lookups public function files(?string $collection = null): MorphMany; // top-level only, ordered by position public function file(string $collection): ?File; // latest, default-first public function getFile(string $collection): ?File; // alias of file() public function defaultFile(string $collection): ?File; public function images(?string $collection = null): MorphMany; // Mutations public function addFile( UploadedFile|\EduLazaro\Laracrate\Support\Binary|FileUpload|string $file, string $collection, array $data = [], array $slots = [], ?Model $creator = null, ?Model $owner = null, ?Folder $folder = null, ): ?File; public function setFile( string $collection, UploadedFile|\EduLazaro\Laracrate\Support\Binary|FileUpload|string|null $file, array $data = [], ?Model $creator = null, ?Model $owner = null, ): ?File; // replaces (force-deletes existing) public function setDefaultFile(File $file): File; public function deleteFile(File $file, bool $forceDelete = false): bool; public function reorderFiles(string $collection, array $orderedIds): void; // Rendering helpers public function fileLink(string $collection, ?string $variant = null, ?string $forceType = null): ?string; public function fileRender(string $collection, ?string $variant = null, array $attrs = []): HtmlString; // Config and tenant resolution public function getCollectionConfig(string $collection): array; public function getDiskFor(string $collection): string; public function resolveFileTenant(): ?Model; // override per app to point at your tenant model
File model
EduLazaro\Laracrate\Models\File. Table laracrate_files. Route key is slug.
// Relations public function fileable(): MorphTo; public function creator(): MorphTo; public function owner(): MorphTo; public function tenant(): MorphTo; public function parent(): BelongsTo; public function children(): HasMany; public function folder(): BelongsTo; public function chunks(): HasMany; // ordered by chunk_index public function chunk(): HasOne; // chunk_index = 0 public function slots(): BelongsToMany; public function contents(): HasMany; // @deprecated alias of chunks() public function content(): HasOne; // @deprecated alias of chunk() public function effectiveOwner(): ?Model; // explicit owner, else creator // Variant navigation (dot notation) public function variant(string $path): self; // falls back to nearest ancestor, never null public function variantOrFail(string $path): self; // throws RuntimeException if a link is missing public function createVariant(string $variantName, array $overrides): self; // Storage key helpers (path stores the full object key) public function getKeyAttribute(): string; // $file->key public function siblingKey(string $newName): string; public function variantKey(string $newName): string; // URLs and rendering public function url(?string $forceType = null): ?string; // public/signed/stream per access public function placeholderFor(string $type): string; public function getLinkAttribute(): ?string; // $file->link, alias of url() public function getPreviewLinkAttribute(): string; // $file->preview_link, thumbnail or placeholder public function streamUrl(): string; // signed package route, TTL config laracrate.urls.route_signed_ttl public function downloadUrl(): string; public function previewUrl(): string; // Folder public function moveToFolder(?Folder $folder): void; // State helpers public function publish(): self; public function unpublish(): self; public function makeDefault(): self; // Type and state predicates public function isVariant(): bool; public function isTopLevel(): bool; public function isSensitive(): bool; public function createdByUser(): bool; public function createdByAgent(): bool; public function createdAutomatically(): bool; public function isMultiTenant(): bool; public function isImage(): bool; public function isVideo(): bool; public function isAudio(): bool; public function isDocument(): bool; public function isPdf(): bool; // Extracted text and chunks (sidecar artifacts on the disk) public function extractedContent(): ?ExtractedContent; public function extractedText(): ?string; public function chunkText(int $chunkIndex): ?string; public function chunksJsonl(): array; public function hasEmbeddings(): bool; // Authorization (delegates to PolicyRegistry) public function canView(?Model $user): bool; public function canEdit(?Model $user): bool; public function canDelete(?Model $user): bool;
Query scopes:
File::topLevel(); // whereNull('parent_id') File::withDescendants($depth = 2); // eager-load children to depth File::withVariants($depth = 3); // eager-load the variant tree File::forTenant($tenant); File::ordered(); // position, then id File::published(); File::unpublished(); File::default();
Scopes, and the state helpers that return self, chain like any Eloquent call:
File::published()->ordered()->forTenant($org)->get(); $file->makeDefault()->publish();
StorageManager service
EduLazaro\Laracrate\Services\StorageManager. The package facade over Storage::disk(). Resolve it with app(StorageManager::class). See the Upload modes and Multi-tenancy, buckets and usage sections for context.
public function urlFor(File $file): ?string; // public/signed/stream based on $file->access public function diskFor(File $file): \Illuminate\Contracts\Filesystem\Filesystem; public function resolveDisk(string $disk): \Illuminate\Contracts\Filesystem\Filesystem; // honors 'tb:{id}' public function configFor(string $disk): array; public function readBinary(File $file): string; public function writeBinary(string $disk, string $key, string $content, ?string $mime = null): bool; public function deleteFromBackend(string $disk, string $key): bool; public function moveServerSide(string $disk, string $fromKey, string $toKey): bool; // S3 copyObject, no PHP round-trip public function batchDelete(string $disk, array $keys): int; public function presignedUpload(string $disk, string $key, string $mime, ?int $maxSize = null, int $minutes = 15): array; public function withLocalCopy(File $file, callable $fn): mixed; // temp file for ffmpeg/Imagick public function getCollectionConfig(string $collection, array $modelOverride = [], ?string $morphAlias = null): array; public function getTypeConfig(string $collection, string $type, ?string $morphAlias = null): array; public function acceptsType(string $collection, string $type, ?string $morphAlias = null): bool; public function s3ClientOf(string $disk): ?\Aws\S3\S3Client; public function driverOf(string $disk): string;
UsageReporter service
EduLazaro\Laracrate\Services\UsageReporter. Storage usage aggregation. Resolve it with app(UsageReporter::class). Each method returns a UsageStats DTO. See the Multi-tenancy, buckets and usage section.
public function forTenant(Model $tenant, bool $excludeTrashed = false): UsageStats; public function forCreator(Model $creator, bool $excludeTrashed = false): UsageStats; public function forCollection(string $collection, ?Model $tenant = null, bool $excludeTrashed = false): UsageStats; public function global(bool $excludeTrashed = false): UsageStats;
Contracts
Bind your own implementation in a service provider to swap any of these. The defaults are documented in their feature sections.
// EduLazaro\Laracrate\Contracts\ChunkStore public function store(File $file, array $chunks): int; public function getByFile(File $file): \Illuminate\Support\Collection; public function search(string $query, array $filters = [], array $options = []): \Illuminate\Support\Collection; public function deleteByFile(File $file): void; public function driverName(): string;
// EduLazaro\Laracrate\Contracts\EmbeddingProvider public function embed(array $texts): array; // one vector per text, same order public function dimensions(): int; public function model(): string; // e.g. "text-embedding-3-small" public function name(): string; // e.g. "openai"
// EduLazaro\Laracrate\Contracts\TextExtractor public function supports(File $file): bool; public function extract(File $file): \EduLazaro\Laracrate\Support\ExtractedContent;
// EduLazaro\Laracrate\Contracts\FileActionInterface public function handle(File $file): void; public function priority(): int; // optional: public function supports(File $file): bool; // assumed true if absent
Testing
Laracrate ships its own test suite (Testbench plus PHPUnit). It runs against a real MySQL database, because the package migrations use MySQL-specific features (FULLTEXT indexes, information_schema lookups, ALTER ... MODIFY) that SQLite cannot run. Object storage is never touched: the AWS S3 client is mocked and the disks are backed by Storage::fake(), so the suite makes no external calls and needs no ffmpeg, imagick, or live S3.
You need PHP with the pdo_mysql extension and a MySQL (or MariaDB) server. Use a dedicated test database: the suite runs migrate:fresh, which drops every table in it.
1. Start a throwaway MySQL
The simplest option is a disposable container:
docker run -d --name laracrate-mysql \ -e MYSQL_ROOT_PASSWORD=root \ -e MYSQL_DATABASE=laracrate_test \ -p 3307:3306 mysql:8
2. Run the suite
composer install vendor/bin/phpunit
The test connection defaults to that container (127.0.0.1:3307, database laracrate_test, user root). Point it elsewhere with environment variables:
LARACRATE_TEST_DB_HOST=127.0.0.1 \ LARACRATE_TEST_DB_PORT=3307 \ LARACRATE_TEST_DB_DATABASE=laracrate_test \ LARACRATE_TEST_DB_USERNAME=root \ LARACRATE_TEST_DB_PASSWORD=root \ vendor/bin/phpunit
The schema is built once per run with migrate:fresh and each test is wrapped in a transaction, so a full run finishes in a few seconds. The PHPUnit config defines two suites, Unit (tests/Unit) and Feature (tests/Feature), keeps the AWS SDK offline, and sets APP_ENV=testing. Any feature you contribute should bring its own test under tests/.
When you are done, remove the container with docker rm -f laracrate-mysql.
Roadmap
The items below are planned or under consideration. They are distilled from the package backlog and may change. Nothing here is a commitment to ship.
- Deferred uploader Livewire component (
laracrate-deferred-uploader): select and preview a file, then commit to storage only on an explicit confirm, with a discard path. Useful for crop UIs, multi-step forms, and avoiding wasted uploads. - Library uploader Livewire component (
laracrate-library-uploader): multi-file collections with a default toggle, drag-and-drop reorder, and per-row delete, the pattern apps build by hand today. tileandbannerlayouts: a square compact layout with hover overlay controls, and a wide cover layout (16:9 or 21:9) for heroes and OG images, both across all uploader themes.- Sync processing in dev: an option to run the processing pipeline inline when there is no queue worker (or when
queue.defaultissync), so a freshly uploaded file is not stuck showing its unoptimized original. php artisan laracrate:doctor: an installation diagnostic that checks disks, backend connectivity, ffmpeg/ffprobe, Imagick/GD, the queue worker, and inconsistent rows.php artisan laracrate:purge-orphan-tmp: cleanup of orphaned temporary uploads left behind when an upload fails midway.- Docs site: a dedicated documentation site once the README outgrows a single file.
Sponsors
Laracrate is supported by the following sponsors. Thank you for keeping it growing:
Author
Created by Edu Lazaro
License
MIT
统计信息
- 总下载量: 121
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 29
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-05-10
