PHPackages                             edulazaro/laracrate - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [File &amp; Storage](/categories/file-storage)
4. /
5. edulazaro/laracrate

ActiveLibrary[File &amp; Storage](/categories/file-storage)

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.

1.0.3(1mo ago)0124↓80%MITBladePHP &gt;=8.2

Since May 10Pushed 1mo agoCompare

[ Source](https://github.com/edulazaro/laracrate)[ Packagist](https://packagist.org/packages/edulazaro/laracrate)[ RSS](/packages/edulazaro-laracrate/feed)WikiDiscussions main Synced 3w ago

READMEChangelog (10)Dependencies (21)Versions (67)Used By (0)

[![Laracrate](art/banner.png)](art/banner.png)

Laracrate
=========

[](#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
-----------------

[](#table-of-contents)

1. [What is Laracrate?](#what-is-laracrate)
2. [Philosophy](#philosophy)
3. [Requirements](#requirements)
4. [Installation](#installation)
5. [Quick start](#quick-start)
6. [Core concepts](#core-concepts)
7. [Data model](#data-model)
8. [Configuration](#configuration)
9. [Working with files from your models](#working-with-files-from-your-models)
10. [Displaying files](#displaying-files)
11. [Upload modes](#upload-modes)
12. [HTTP endpoints](#http-endpoints)
13. [Folders](#folders)
14. [File slots](#file-slots)
15. [Processing pipeline](#processing-pipeline)
16. [Images, variants and watermarks](#images-variants-and-watermarks)
17. [Video and PDF previews](#video-and-pdf-previews)
18. [Text extraction, embeddings and search (RAG)](#text-extraction-embeddings-and-search-rag)
19. [Access control and authorization](#access-control-and-authorization)
20. [Sensitive content and encryption](#sensitive-content-and-encryption)
21. [Multi-tenancy, buckets and usage](#multi-tenancy-buckets-and-usage)
22. [Livewire components and themes](#livewire-components-and-themes)
23. [Localization](#localization)
24. [Artisan commands](#artisan-commands)
25. [Events](#events)
26. [API reference](#api-reference)
27. [Testing](#testing)
28. [Roadmap](#roadmap)
29. [Sponsors](#sponsors)
30. [Author](#author)
31. [License](#license)

What is Laracrate?
------------------

[](#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), and `stream` (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
----------

[](#philosophy)

Laracrate follows six principles. Keep them in mind and the rest of the API will feel predictable.

1. **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.
2. **Reuse `Storage::disk()`.** Disk credentials live in your app's `config/filesystems.php`, not here. Laracrate resolves disks through Laravel's filesystem and never duplicates that configuration.
3. **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.
4. **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 `pending` until a worker picks it up.
5. **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.
6. **`path` is the full object key.** A file's `path` stores the complete key of the object in the disk (directories, filename, extension included). Use the `key` accessor to read it. Never rebuild a key by concatenating `path` and `name`.

Requirements
------------

[](#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.

WhatRequirementNotesPHP`>= 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 dependencyUnlocks`smalot/pdfparser`Native PDF text extraction (`PdfTextExtractor`).`imagick` or `gd` PHP extensionImage 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 serverThe `meilisearch` chunk store for server-side hybrid search (see the Text extraction, embeddings and search section).An OpenAI or Anthropic API keyEmbeddings, 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
------------

[](#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

[](#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

[](#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-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.

TagWhat it copiesDestination`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

[](#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
-----------

[](#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

[](#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

[](#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

[](#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

[](#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) --}}

{{-- The 'medium' variant --}}

```

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

[](#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
-------------

[](#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.

MorphColumnsMeaning`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:

CaseHow 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
----------

[](#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

[](#tables)

TableModelPurpose`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`

[](#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**

ColumnTypeNotes`id`bigintPrimary key.`slug`ulidUnique. Used as the public route key (never expose `id`).`parent_id`bigint, nullablePoints at the parent file. Null means top-level. Set means this row is a variant. Cascades on delete.`variant`string(50), nullableVariant name (`thumbnail`, `preview`, `small`, ...). Unique together with `parent_id`.`folder_id`bigint, nullableFolder 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):

ColumnsRelationMeaning`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**

ColumnTypeNotes`disk`stringThe Laravel disk name, or a `tb:{id}` token for a dedicated tenant bucket.`path`stringThe full object key in the disk (directories, filename, extension). Read it through `$file->key`, never by hand.`name`string`basename($path)` denormalized.`original_name`stringThe filename as uploaded.`extension`string(10)Lowercase extension.`mime_type`string(100)Detected MIME type.`size`unsignedBigIntegerBytes.`digest`string(80), nullableContent hash for dedupe or integrity.**Classification**

ColumnTypeNotes`context`stringDefaults to `laracrate.default_context`. Indexed.`collection`stringDefaults to `laracrate.default_collection`. Indexed.`type`enum`image`, `video`, `audio`, `document`. Cast to `FileType`. Indexed.`category`string, nullableFree-form app category. Indexed.**Access flags**

ColumnTypeNotes`access`enum`public`, `signed`, `stream`. Defaults to `signed`. Cast to `FileAccess`. Indexed.`visibility`string, nullableFree-form visibility label. The `FileVisibility` enum (`owner`, `group`, `tenant`, `world`) is available if you want to use its values. Indexed.`sensitive`booleanDefaults to false. Indexed.`is_encrypted`booleanDefaults to false.**Metadata and presentation**

ColumnTypeNotes`title`string, nullableDisplay title.`description`text, nullableDisplay description.`label`string(100), nullableShort label.`default`booleanMarks the default file in its (fileable + collection) group.`position`unsignedIntegerSort order. Defaults to 0.`published`booleanDefaults to true. Indexed.`is_verified`booleanDefaults to false. Indexed.`metadata`json, nullableFree-form bag. Cast to array.**Media metadata**

ColumnTypeNotes`duration`unsignedInteger, nullableSeconds, for video and audio.`width`, `height`unsignedInteger, nullablePixels, for image and video.`bitrate`unsignedInteger, nullableFor video and audio.`sample_rate`unsignedInteger, nullableFor audio.**Processing and audit**

ColumnTypeNotes`processing_status`enum, nullable`pending`, `processing`, `completed`, `failed`. Cast to `ProcessingStatus`.`processing_error`text, nullableError message when a step throws.`processing_started_at`timestamp, nullableWhen the pipeline began.`processing_extractor`string(255), nullableClass of the text extractor used.`processing_provider`string(50), nullableEmbedding or extraction provider.`processing_model`string(100), nullableModel used for embedding or extraction.`summary`text, nullableOptional LLM-distilled summary of the extracted content.`downloads_count`unsignedIntegerDefaults to 0.`last_downloaded_at`timestamp, nullableUpdated 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).

ColumnTypeNotes`mysql_indexed_at`timestamp, nullableChunks ready in MySQL (LIKE keyword plus cosine in PHP). Indexed.`meili_indexed_at`timestamp, nullableChunks 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`

[](#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.

ColumnTypeNotes`id`bigintPrimary key.`file_id`bigintParent file. Cascades on delete.`chunk_index`unsignedIntegerPosition within the file. Defaults to 0. Unique together with `file_id`.`context`string(30), nullableDiscriminator for multi-section extractions (`text` for OCR verbatim, `description` for a generated visual description, or any opaque label). Indexed with `file_id`.`text`longText, nullableChunk text. The column has a FULLTEXT index, though the MySQL chunk store currently keyword-matches with SQL LIKE.`embedding`json, nullableEmbedding vector. Cast to array. Cosine similarity is computed in PHP.`tokens`unsignedInteger, nullableToken count for the chunk.`metadata`json, nullableFree-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`

[](#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`

[](#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`

[](#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`

[](#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`

[](#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
-------------

[](#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

[](#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

[](#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 ``.
- **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, OGVThe 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

[](#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

[](#anatomy-of-a-collection-entry)

KeyTypePurpose`disk`stringThe `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`boolBinds access to the authenticated viewer and re-validates on each request. See the Sensitive content section.`encrypt`boolEncrypts the binary before it is stored on the backend. See the Sensitive content and encryption section.`ttl_hours`intFiles 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`intStorage 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`boolMaintain 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`stringBlade component used for default rendering.`placeholder`stringFallback asset when the file does not exist (highest priority in placeholder resolution, below).`types`arrayPer-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`arrayImage variant definitions: `['name' => ['width' => ..., 'height' => ..., 'fit' => bool, 'watermark' => bool]]`. See the Images, variants and watermarks section.`preview`arrayRasterized 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 arrayWhether 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 arrayWhether to generate embeddings. See the Text extraction, embeddings and search section.`actions`arrayCustom actions to attach to the collection.`models`arrayPer-model scoping (covered next).#### The `models` block: per-model scoping

[](#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 triggers `EduLazaro\Laracrate\Exceptions\CollectionNotAllowedForModel`.
- The per-model override is merged over the base with `array_replace_recursive`, so nested structures (like `variants`) merge key by key.
- The `models` key 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 by `CreateFileAction`. It is not configurable through a `path` key.
- 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`

[](#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

[](#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:

1. `config('laracrate.collections.{name}.placeholder')`
2. `config('laracrate.placeholders.{type}')`
3. `config('laracrate.placeholders.default')`

#### Dynamic placeholders (initials avatars, generated SVGs)

[](#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 = ''
             . ''
             . ''
             . htmlspecialchars($initials, ENT_QUOTES | ENT_XML1) . '';

        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

[](#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,
],
```

KeyDefaultMeaning`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

[](#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

[](#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

[](#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

[](#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' => [],
],
```

KeyDefaultMeaning`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

[](#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,
],
```

KeyDefaultMeaning`threshold`100 MBBelow this the client should use a single presigned PUT, at or above it multipart.`part_size`10 MBBytes 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)

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

[](#pdf-preview-engine)

Selects the engine that rasterizes a PDF page into a PNG for the `preview` variant.

```
'pdf_preview_engine' => 'auto',
```

ValueRequirementsNotes`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

[](#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

[](#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

[](#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,
],
```

KeyDefaultMeaning`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

[](#chunks)

Selects the `ChunkStore` backend that persists and searches text chunks.

```
'chunks' => [
    'driver' => env('LARACRATE_CHUNKS_DRIVER', 'mysql'),
],
```

DriverStorageNotes`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 embeddingsNative 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

[](#meilisearch)

Applies only when `chunks.driver` is `meilisearch`.

```
'meilisearch' => [
    'index'    => env('LARACRATE_MEILISEARCH_INDEX', 'laracrate_file_chunks'),
    'embedder' => env('LARACRATE_MEILISEARCH_EMBEDDER', 'default'),
],
```

### OCR

[](#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

[](#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,
    ],
],
```

KeyDefaultMeaning`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

[](#ui)

Default theme for the `` 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

[](#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
-----------------------------------

[](#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

[](#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
```

ArgumentPurpose`$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

[](#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

[](#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

[](#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

[](#querying-files)

MethodReturnsNotes`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
----------------

[](#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)

@endif
```

For Blade convenience the model exposes accessors so you can skip the method call:

AccessorEquivalent to`$file->link``$file->url()``$file->preview_link``$file->variant('preview.thumbnail')->url('image')`, falling back to the image placeholder```

 {{-- 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

[](#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-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

[](#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 `` always has a `src`:

```

     {{-- a variant --}}

```

```
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 ``; 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

[](#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:

```

```

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)
    merge(['class' => 'rounded-full']) }}>
@else
    merge(['class' => 'rounded-full bg-gray-200 grid place-items-center']) }}>
        {{ Str::of($model->name)->substr(0, 1)->upper() }}

@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
------------

[](#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.

ModeHow the bytes travelBest forProsCons**Server-side**Browser to your PHP, then PHP to the diskSmall files, simple forms, trusted server flowsOne request, no JS, works on any diskBytes pass through PHP (memory, request size limits)**Direct presigned PUT**Browser straight to S3/R2 via a presigned URLMost uploads up to roughly 100 MBOffloads bandwidth from PHP, progress eventsNeeds S3-compatible disk (or the local fallback), two steps**Multipart**Browser uploads in parts straight to S3/R2Large files (video, archives)Parallel parts, resumable, no PHP transferS3/R2 only, more orchestrationThe 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)

[](#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

[](#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)

[](#multipart-large-files)

For files past the threshold, S3/R2 multipart splits the upload into parts that the browser `PUT`s 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`:

1. **Init.** `POST /laracrate/multipart/init` with `disk`, `expected_size`, and optionally `mime`, `file_name`, `part_size`, `fileable_type`, `fileable_id`, `collection`. The response gives you `upload_id`, `id`, `key`, `part_size`, `total_parts`, `expires_at`, and `parts` (an array of `{ part_number, url, method }`).
2. **Upload parts.** `PUT` each part's bytes to its `url`. Capture the `ETag` response header for every part.
3. **Reissue (optional).** If a part URL expires before you use it, `POST /laracrate/multipart/{id}/parts` with `part_numbers` to get fresh URLs.
4. **Complete.** `POST /laracrate/multipart/{id}/complete` with `parts` as `[{ part_number, etag }, ...]`. S3 assembles the object at `key`. 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

[](#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
--------------

[](#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:

MethodURIRoute namePurposePOST`laracrate/uploads/presign``laracrate.uploads.presign`Mint a presigned PUT URL (or local signed-route fallback) for a direct uploadDELETE`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 URLsPOST`laracrate/multipart/{multipart}/parts``laracrate.multipart.parts`Reissue presigned URLs for specific partsPOST`laracrate/multipart/{multipart}/complete``laracrate.multipart.complete`Assemble the final object from uploaded part ETagsDELETE`laracrate/multipart/{multipart}``laracrate.multipart.abort`Abort and clean up a multipart sessionGET`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 variantGET`laracrate/files/{file:slug}/download``laracrate.files.download`Download a protected file as an attachmentGET`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 requestPOST`_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:

GroupPrefix configMiddleware configName prefixPresigned 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 driverfixed `_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).
- **`cancel` only touches `temp/`.** The DELETE endpoint refuses any key that does not start with `temp/`, so it cannot be used to delete canonical objects. The `deleteTemp()` JS helper builds the `{encodedKey}` segment for you.
- **Multipart ownership.** `parts`, `complete`, and `abort` verify 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/local` group is protected by Laravel's `signed` middleware. The URLs are minted by `StorageManager::presignedUpload()` and `GenerateSignedUrlAction`, 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 the `pollFileStatus` and `pollFilesStatus` JS helpers to consume them; see the displaying files and processing pipeline sections for how `ready` and `variants` are populated.

Folders
-------

[](#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

[](#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

[](#listing-the-tree)

CallReturns`$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

[](#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

[](#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

[](#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 `folderable` morph backs two unrelated features. The `Folder` model organizes files in a tree, while the separate `Folderable` model 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
----------

[](#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

[](#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(),
]);
```

ColumnTypePurpose`name`stringSlot label shown to the uploader`description`string, nullableOptional helper text`color`string, nullableOptional UI color`allowed_extensions`array, nullableAllowed file extensions (empty = any)`allowed_types`array, nullableAllowed `FileType` values: `document`, `image`, `video`, `audio` (empty = any)`max_files_per_creator`int, nullablePer-creator limit (null = unlimited)`max_files_total`int, nullableGlobal limit across all creators (null = unlimited)`position`intDisplay order, defaults to 0`tenant_type` / `tenant_id`morph, nullableMulti-tenant scope, same convention as `File``context_type` / `context_id`morph, nullableOptional finer scope inside the tenant (for example one case)### Attaching files to slots

[](#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

[](#checking-rules-and-completion)

`FileSlot` exposes the predicates used during upload, so you can drive UI and your own validation with the same logic.

MethodReturns`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
-------------------

[](#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:

1. A top-level `File` is created. `FileObserver::created` sets `processing_status` to `pending` and dispatches `ProcessFileJob`.
2. `ProcessFileJob` runs on the queue and calls `ProcessFileAction`.
3. `ProcessFileAction` marks the file `processing` (firing `FileProcessingStarted`), resolves the applicable steps, runs them in ascending `priority()` order, then marks the file `completed` and fires `FileProcessed`.

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

[](#default-steps)

Each step is a small class implementing `EduLazaro\Laracrate\Contracts\FileActionInterface`. The package registers these globally in `LaracrateServiceProvider`, ordered here by `priority()`:

PriorityStep class (namespace `EduLazaro\Laracrate\Pipeline\Steps\...`)Runs when10`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 file70`Text\ChunkTextStep`embeddings enabled, the collection should embed, and the `{key}.json` sidecar exists80`Text\GenerateEmbeddingStep`embeddings enabled, the collection should embed, and the `{key}.chunks.jsonl` sidecar exists90`Text\PersistChunksStep`the `{key}.chunks.jsonl` sidecar existsThe 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-bands)

`priority()` returns an ascending integer. Lower numbers run first. Follow this convention so your steps slot in at the right point:

BandPurpose0-19Metadata (dimensions, duration)20-39Transforming the original (optimize, transcode, encrypt)40-59Derivatives (variants, previews, thumbnails)60-79Semantic extraction (text, OCR, transcription)80-99AI (chunking, embeddings, classification)100+App-specific post-processing### Failure and retry behavior

[](#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

[](#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.

```
