Initial release

This commit is contained in:
Fabian @ Blax Software 2026-04-14 10:20:55 +02:00
commit 62d2273557
36 changed files with 4861 additions and 0 deletions

10
.gitattributes vendored Normal file
View File

@ -0,0 +1,10 @@
/.vscode export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/.phpunit.cache export-ignore
/.phpunit.result.cache export-ignore
/phpunit.xml export-ignore
/pint.json export-ignore
/workbench export-ignore
/tests export-ignore
/docs export-ignore

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
/vendor/
/node_modules/
composer.lock
.phpunit.cache/
.phpunit.result.cache
.php-cs-fixer.cache
.idea/
.vscode/
*.swp
*.swo
.DS_Store
Thumbs.db
workbench/

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) Blax Software <office@blax.at>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

102
README.md Normal file
View File

@ -0,0 +1,102 @@
[![Blax Software OSS](https://raw.githubusercontent.com/blax-software/laravel-workkit/master/art/oss-initiative-banner.svg)](https://github.com/blax-software)
# Laravel Files
A universal, plug-and-play file management system for Laravel. Upload, optimize, serve, and attach files to any Eloquent model — with disk-agnostic storage, automatic image optimization, chunked uploads, and a built-in warehouse endpoint.
---
## Features
- **Attach files to any model** — polymorphic MorphToMany relationship via the `HasFiles` trait
- **Role-based attachments** — tag files as `avatar`, `gallery`, `document`, etc. using the `FileLinkType` enum or custom strings
- **Disk-agnostic** — works with any Laravel filesystem disk (local, S3, GCS, …)
- **Automatic image optimization** — on-the-fly resizing and WebP conversion via [spatie/image](https://github.com/spatie/image)
- **Chunked uploads** — upload large files in pieces with real-time progress broadcasting
- **Warehouse endpoint** — a single route that resolves and serves any file by UUID, encrypted ID, or asset path
- **UUID primary keys** — every file gets a unique, non-sequential identifier
- **Artisan cleanup** — remove orphaned files that are no longer attached to any model
---
## Quick Start
```bash
composer require blax-software/laravel-files
```
Publish the config and migrations:
```bash
php artisan vendor:publish --tag=files-config
php artisan vendor:publish --tag=files-migrations
php artisan migrate
```
Add the trait to any model:
```php
use Blax\Files\Traits\HasFiles;
class User extends Model
{
use HasFiles;
}
```
Attach a file:
```php
use Blax\Files\Enums\FileLinkType;
$user = User::find(1);
// Upload and attach in one call
$file = $user->uploadFile($request->file('avatar'), as: FileLinkType::Avatar, replace: true);
// Get the avatar back
$avatar = $user->getAvatar();
echo $avatar->url; // → /warehouse/019d8ab8-…
echo $avatar->size_human; // → "2.4 MB"
```
---
## Documentation
| Guide | Description |
|--------------------------------------------------|---------------------------------------------------------------|
| [Installation](docs/installation.md) | Requirements, installation, configuration |
| [Attaching Files](docs/attaching-files.md) | The `HasFiles` trait, roles, attach/detach, reordering |
| [File Operations](docs/file-operations.md) | Creating files, reading/writing contents, duplication, scopes |
| [Uploading](docs/uploading.md) | Single uploads, chunked uploads, progress events |
| [Serving Files](docs/serving-files.md) | The Warehouse, inline responses, downloads |
| [Image Optimization](docs/image-optimization.md) | On-the-fly resizing, WebP, quality control |
| [Configuration](docs/configuration.md) | Full reference for `config/files.php` |
| [Artisan Commands](docs/artisan-commands.md) | `files:cleanup` and maintenance |
---
## Optional Dependencies
| Package | Purpose |
|------------------------------------|------------------------------------------------|
| `spatie/image ^3.8` | Image optimization and resizing |
| `blax-software/laravel-roles` | Access control on the warehouse endpoint |
| `blax-software/laravel-websockets` | Real-time chunk upload progress via WebSockets |
---
## Testing
```bash
composer test
# or
./vendor/bin/phpunit
```
---
## License
MIT — see [LICENSE](LICENSE) for details.

72
composer.json Normal file
View File

@ -0,0 +1,72 @@
{
"name": "blax-software/laravel-files",
"type": "library",
"description": "Universal Laravel file management system — upload, optimize, serve, and attach files to any model.",
"keywords": [
"files",
"upload",
"media",
"image",
"optimization",
"warehouse",
"chunk-upload",
"laravel",
"blax"
],
"homepage": "http://www.blax.at",
"license": "MIT",
"authors": [
{
"name": "Fabian Wagner",
"email": "fabian@blax.at",
"homepage": "https://www.blax.at",
"role": "Developer"
}
],
"autoload": {
"psr-4": {
"Blax\\Files\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"Blax\\Files\\Tests\\": "tests",
"Workbench\\App\\": "workbench/app",
"Workbench\\Database\\Factories\\": "workbench/database/factories"
}
},
"config": {
"sort-packages": true
},
"require": {
"php": "^8.1",
"blax-software/laravel-workkit": "*",
"illuminate/container": "^9.0|^10.0|^11.0|^12.0",
"illuminate/contracts": "^9.0|^10.0|^11.0|^12.0",
"illuminate/database": "^9.0|^10.0|^11.0|^12.0",
"illuminate/http": "^9.0|^10.0|^11.0|^12.0",
"illuminate/routing": "^9.0|^10.0|^11.0|^12.0",
"illuminate/support": "^9.0|^10.0|^11.0|^12.0"
},
"require-dev": {
"laravel/framework": "*",
"laravel/pint": "^1.22",
"orchestra/testbench": "^10.4",
"phpunit/phpunit": "^12.2",
"spatie/image": "^3.8"
},
"suggest": {
"blax-software/laravel-roles": "For access control on files via roles & permissions.",
"blax-software/laravel-websockets": "For real-time chunk upload progress via WebSockets.",
"spatie/image": "Required for automatic image optimization and resizing (^3.8)."
},
"extra": {
"laravel": {
"providers": [
"Blax\\Files\\FilesServiceProvider"
]
}
},
"minimum-stability": "dev",
"prefer-stable": true
}

118
config/files.php Normal file
View File

@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Models
|--------------------------------------------------------------------------
|
| Override these to use your own model classes.
|
*/
'models' => [
'file' => \Blax\Files\Models\File::class,
'filable' => \Blax\Files\Models\Filable::class,
],
/*
|--------------------------------------------------------------------------
| Table Names
|--------------------------------------------------------------------------
*/
'table_names' => [
'files' => 'files',
'filables' => 'filables',
],
/*
|--------------------------------------------------------------------------
| Default Disk
|--------------------------------------------------------------------------
|
| The filesystem disk used for storing files. Any disk configured in
| config/filesystems.php is supported (local, s3, gcs, ).
|
*/
'disk' => env('FILES_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Storage Path Template
|--------------------------------------------------------------------------
|
| The relative path template for new uploads. Placeholders:
| {user_id} authenticated user ID or "anonymous"
| {uuid} generated UUID of the file
| {date} Y/m/d subdirectory
|
*/
'storage_path' => 'files/{date}/{uuid}',
/*
|--------------------------------------------------------------------------
| Warehouse Route
|--------------------------------------------------------------------------
|
| These settings control the public warehouse route that serves files.
|
*/
'warehouse' => [
'enabled' => true,
'prefix' => 'warehouse',
'middleware' => ['web'],
],
/*
|--------------------------------------------------------------------------
| Upload Settings
|--------------------------------------------------------------------------
*/
'upload' => [
'max_size' => 50 * 1024, // KB (50 MB)
'chunk_size' => 1024, // KB per chunk (1 MB)
'allowed_mimes' => [], // empty = allow all
'route_prefix' => 'api/files',
'middleware' => ['api', 'auth:sanctum'],
],
/*
|--------------------------------------------------------------------------
| Image Optimization
|--------------------------------------------------------------------------
|
| Requires spatie/image ^3.8. Optimization is skipped if not installed.
|
*/
'optimization' => [
'enabled' => true,
'default_quality' => 85,
'webp_conversion' => true,
'round_dimensions' => true,
'round_to' => 50,
'skip_formats' => ['gif', 'svg', 'svg+xml'],
'preferred_extensions' => ['svg', 'webp', 'png', 'jpg', 'jpeg'],
],
/*
|--------------------------------------------------------------------------
| Access Control
|--------------------------------------------------------------------------
|
| When laravel-roles is installed, files can be protected via access
| checks. Set 'enabled' to false to serve all files publicly.
|
*/
'access_control' => [
'enabled' => false,
],
];

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create(config('files.table_names.filables', 'filables'), function (Blueprint $table) {
$table->id();
$table->uuid('file_id');
$table->morphs('filable');
$table->string('as')->nullable()->index();
$table->smallInteger('order')->nullable()->default(null);
$table->json('meta')->nullable();
$table->timestamps();
$table->foreign('file_id')
->references('id')
->on(config('files.table_names.files', 'files'))
->cascadeOnDelete();
$table->unique(['file_id', 'filable_type', 'filable_id', 'as'], 'filables_unique');
});
}
public function down(): void
{
Schema::dropIfExists(config('files.table_names.filables', 'filables'));
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create(config('files.table_names.files', 'files'), function (Blueprint $table) {
$table->uuid('id')->primary();
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->string('name')->nullable();
$table->string('type')->nullable();
$table->string('extension')->nullable();
$table->unsignedBigInteger('size')->nullable();
$table->string('disk')->default(config('files.disk', 'local'));
$table->string('relativepath')->nullable();
$table->json('meta')->nullable();
$table->timestamp('last_accessed_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists(config('files.table_names.files', 'files'));
}
};

48
docs/artisan-commands.md Normal file
View File

@ -0,0 +1,48 @@
# Artisan Commands
## `files:cleanup`
Remove orphaned files — files that are not attached to any model.
```bash
php artisan files:cleanup
```
### Options
| Option | Default | Description |
|-------------|---------|---------------------------------------------------------|
| `--days=N` | `30` | Only delete orphans older than N days |
| `--dry-run` | — | Preview what would be deleted without actually deleting |
### Examples
```bash
# Preview orphaned files older than 30 days
php artisan files:cleanup --dry-run
# Delete orphaned files older than 7 days
php artisan files:cleanup --days=7
# Delete all orphans older than 90 days
php artisan files:cleanup --days=90
```
### What Counts as Orphaned?
A file is orphaned when it has **zero** entries in the `filables` pivot table — meaning no model references it. This uses the `File::orphaned()` scope internally.
### Scheduling
Add to your `app/Console/Kernel.php` (or `routes/console.php` in Laravel 11+):
```php
Schedule::command('files:cleanup --days=30')->daily();
```
### Safety
- Only files older than the `--days` threshold are eligible
- Each file's disk contents and resized variants are deleted along with the model
- Pivot entries (if any remained) are also cleaned up
- Use `--dry-run` first to verify the impact

260
docs/attaching-files.md Normal file
View File

@ -0,0 +1,260 @@
# Attaching Files to Models
The `HasFiles` trait connects any Eloquent model to the file system. Add the trait, and your model gains a polymorphic many-to-many relationship with the `File` model.
## Setup
```php
use Blax\Files\Traits\HasFiles;
class Product extends Model
{
use HasFiles;
}
```
No further configuration needed — the trait registers a `files()` relationship automatically.
## The `files()` Relationship
```php
$product->files; // Collection of all attached File models
$product->files()->count(); // number of files
$product->files()->get(); // query builder — add your own constraints
```
Every pivot row carries three extra columns: `as` (role), `order`, and `meta` (JSON).
---
## Attaching Files
### `attachFile()`
```php
$product->attachFile($file, as: FileLinkType::Gallery, order: 0);
```
| Parameter | Type | Default | Description |
|------------|------------------------------|---------|------------------------------------------------------------------|
| `$file` | `File\|string` | — | A File model or its UUID |
| `$as` | `string\|FileLinkType\|null` | `null` | Role / category for this attachment |
| `$order` | `?int` | `null` | Sort position |
| `$meta` | `?array` | `null` | Arbitrary metadata stored as JSON on the pivot |
| `$replace` | `bool` | `false` | If `true`, removes existing attachments with the same role first |
Duplicate prevention is built-in — attaching the same file with the same role twice is a no-op.
#### Replace Mode
Use `replace: true` for singular fields like avatars:
```php
$user->attachFile($newAvatar, as: FileLinkType::Avatar, replace: true);
// The old avatar is detached; the new one takes its place.
```
#### Storing Metadata
```php
$product->attachFile($file, as: 'document', meta: [
'description' => 'Product specification sheet',
'version' => '2.1',
]);
```
### Method Chaining
All attach/detach methods return `$this`, so you can chain:
```php
$product
->attachFile($logo, as: FileLinkType::Logo, replace: true)
->attachFile($hero, as: FileLinkType::Banner, replace: true)
->attachFile($spec, as: FileLinkType::Document);
```
---
## Detaching Files
### `detachFile()`
Remove a specific file (optionally scoped by role):
```php
$product->detachFile($file);
$product->detachFile($file, as: 'gallery');
```
### `detachFilesAs()`
Remove **all** files with a given role:
```php
$product->detachFilesAs(FileLinkType::Gallery);
$product->detachFilesAs('document');
```
### `detachAllFiles()`
Remove every file attachment from the model:
```php
$product->detachAllFiles();
```
> **Note:** Detaching does not delete the `File` record or its contents — it only removes the pivot link. To delete a file permanently, call `$file->delete()`.
---
## Querying Attached Files
### By Role
```php
// Get all gallery images
$images = $product->filesAs(FileLinkType::Gallery)->get();
// Get the first document
$doc = $product->fileAs('document');
```
### Convenience Getters
These methods resolve common roles with built-in fallback logic:
```php
$user->getAvatar(); // tries Avatar, then ProfileImage
$user->getThumbnail();
$user->getBanner();
$user->getCoverImage();
$user->getBackground();
$user->getLogo();
$user->getGallery(); // returns a Collection
```
Each returns a `?File` (or a `Collection` for `getGallery()`).
---
## Pivot Access
### Reading Pivot Data
```php
$pivot = $product->getFilePivot($file); // returns ?Filable
$pivot->as; // 'gallery'
$pivot->order; // 2
$pivot->meta; // ['description' => '...']
```
### Updating Pivot Data
The `Filable` model provides convenient setters:
```php
$pivot->setAs(FileLinkType::Banner); // updates and saves
$pivot->setOrder(5); // updates and saves
$pivot->getLinkType(); // returns ?FileLinkType enum
```
---
## Reordering Files
Reorder a set of files by passing their IDs in the desired order:
```php
$product->reorderFiles([
$fileC->id,
$fileA->id,
$fileB->id,
]);
```
Scope to a specific role:
```php
$product->reorderFiles($ids, as: FileLinkType::Gallery);
```
The pivot `order` column is set to the array index (0, 1, 2, …). Files are auto-sorted by `order` thanks to a global scope on the `Filable` model.
---
## Upload Helpers
The trait includes three convenience methods that create a file **and** attach it in a single call:
### `uploadFile()`
Upload from a Laravel `UploadedFile` (e.g. `$request->file('photo')`):
```php
$file = $product->uploadFile(
$request->file('photo'),
as: FileLinkType::Gallery,
order: 0,
replace: false,
);
```
### `uploadFileFromContents()`
Create a file from raw string content:
```php
$file = $product->uploadFileFromContents(
contents: $pdfBinary,
name: 'invoice-2024',
extension: 'pdf',
as: FileLinkType::Invoice,
replace: true,
);
```
### `uploadFileFromUrl()`
Download from a remote URL and attach:
```php
$file = $product->uploadFileFromUrl(
url: 'https://example.com/photo.jpg',
name: 'product-hero',
as: FileLinkType::Banner,
replace: true,
);
```
All three return the created `File` model.
---
## FileLinkType Enum
The `FileLinkType` enum provides 19 predefined roles grouped into categories:
| Group | Cases |
|-----------------|---------------------------------------------------------------------------------------------|
| Visual Identity | `Avatar`, `ProfileImage`, `CoverImage`, `Banner`, `Background`, `Logo`, `Icon`, `Thumbnail` |
| Documents | `Document`, `Invoice`, `Contract`, `Certificate`, `Report` |
| Media | `Gallery`, `Video`, `Audio` |
| Attachments | `Attachment`, `Download` |
| Catch-All | `Other` |
```php
use Blax\Files\Enums\FileLinkType;
FileLinkType::Avatar->value; // 'avatar'
FileLinkType::Avatar->label(); // 'Avatar'
FileLinkType::Avatar->isImage(); // true
FileLinkType::Document->isImage(); // false
```
You can also pass plain strings as the `$as` parameter if the built-in enum doesn't fit your use case.
---
Next: [File Operations](file-operations.md)

205
docs/configuration.md Normal file
View File

@ -0,0 +1,205 @@
# Configuration Reference
After publishing the config file (`php artisan vendor:publish --tag=files-config`), you'll find `config/files.php` with the following sections.
---
## Models
Override the default models with your own implementations:
```php
'models' => [
'file' => \Blax\Files\Models\File::class,
'filable' => \Blax\Files\Models\Filable::class,
],
```
Custom models should extend the package models.
---
## Table Names
```php
'table_names' => [
'files' => 'files',
'filables' => 'filables',
],
```
Change these **before** running migrations if you need custom table names.
---
## Disk
```php
'disk' => env('FILES_DISK', 'local'),
```
Any disk from `config/filesystems.php` works: `local`, `public`, `s3`, etc. Set via `FILES_DISK` in your `.env`.
---
## Storage Path Template
```php
'storage_path' => 'files/{date}/{uuid}',
```
Defines how uploaded files are organized on disk. Available placeholders:
| Placeholder | Value |
|-------------|-----------------------------------------|
| `{user_id}` | Authenticated user ID, or `"anonymous"` |
| `{uuid}` | File's UUID |
| `{date}` | Current date as `Y/m/d` |
Examples:
```php
'storage_path' => 'uploads/{user_id}/{date}/{uuid}',
'storage_path' => 'files/{uuid}',
```
---
## Warehouse
```php
'warehouse' => [
'enabled' => true,
'prefix' => 'warehouse',
'middleware' => ['web'],
],
```
| Key | Type | Default | Description |
|--------------|----------|---------------|---------------------------------------|
| `enabled` | `bool` | `true` | Register the warehouse route |
| `prefix` | `string` | `'warehouse'` | URL prefix for the route |
| `middleware` | `array` | `['web']` | Middleware group applied to the route |
---
## Upload
```php
'upload' => [
'max_size' => 50 * 1024, // 50 MB in KB
'chunk_size' => 1024, // 1 MB per chunk
'allowed_mimes' => [], // empty = allow all
'route_prefix' => 'api/files',
'middleware' => ['api', 'auth:sanctum'],
],
```
| Key | Type | Default | Description |
|-----------------|----------|---------------------------|-----------------------------------------------------|
| `max_size` | `int` | `51200` | Max file size in KB |
| `chunk_size` | `int` | `1024` | Chunk size in KB |
| `allowed_mimes` | `array` | `[]` | Allowed MIME types/extensions. Empty = all allowed. |
| `route_prefix` | `string` | `'api/files'` | URL prefix for upload routes |
| `middleware` | `array` | `['api', 'auth:sanctum']` | Middleware for upload routes |
### Restricting MIME Types
```php
'allowed_mimes' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'docx'],
```
This is enforced at the controller level via Laravel's `mimes` validation rule.
---
## Image Optimization
```php
'optimization' => [
'enabled' => true,
'default_quality' => 85,
'webp_conversion' => true,
'round_dimensions' => true,
'round_to' => 50,
'skip_formats' => ['gif', 'svg', 'svg+xml'],
'preferred_extensions' => ['svg', 'webp', 'png', 'jpg', 'jpeg'],
],
```
| Key | Type | Default | Description |
|------------------------|---------|-----------------------------------------|--------------------------------------------------|
| `enabled` | `bool` | `true` | Enable image optimization features |
| `default_quality` | `int` | `85` | Default JPEG/WebP quality (1100) |
| `webp_conversion` | `bool` | `true` | Convert images to WebP by default |
| `round_dimensions` | `bool` | `true` | Round resize dimensions to reduce cache variants |
| `round_to` | `int` | `50` | Rounding step in pixels |
| `skip_formats` | `array` | `['gif', 'svg', 'svg+xml']` | Formats that bypass optimization |
| `preferred_extensions` | `array` | `['svg', 'webp', 'png', 'jpg', 'jpeg']` | Extension order for auto-resolution |
See [Image Optimization](image-optimization.md) for detailed usage.
---
## Access Control
```php
'access_control' => [
'enabled' => false,
],
```
| Key | Type | Default | Description |
|-----------|--------|---------|--------------------------------------------------------|
| `enabled` | `bool` | `false` | Enable role-based access checks on the warehouse route |
Requires [laravel-roles](https://github.com/blax-software/laravel-roles). See [Serving Files](serving-files.md#access-control).
---
## Full Default Config
```php
<?php
return [
'models' => [
'file' => \Blax\Files\Models\File::class,
'filable' => \Blax\Files\Models\Filable::class,
],
'table_names' => [
'files' => 'files',
'filables' => 'filables',
],
'disk' => env('FILES_DISK', 'local'),
'storage_path' => 'files/{date}/{uuid}',
'warehouse' => [
'enabled' => true,
'prefix' => 'warehouse',
'middleware' => ['web'],
],
'upload' => [
'max_size' => 50 * 1024,
'chunk_size' => 1024,
'allowed_mimes' => [],
'route_prefix' => 'api/files',
'middleware' => ['api', 'auth:sanctum'],
],
'optimization' => [
'enabled' => true,
'default_quality' => 85,
'webp_conversion' => true,
'round_dimensions' => true,
'round_to' => 50,
'skip_formats' => ['gif', 'svg', 'svg+xml'],
'preferred_extensions' => ['svg', 'webp', 'png', 'jpg', 'jpeg'],
],
'access_control' => [
'enabled' => false,
],
];
```
---
Next: [Artisan Commands](artisan-commands.md)

180
docs/file-operations.md Normal file
View File

@ -0,0 +1,180 @@
# File Operations
The `File` model is the central entity. It uses UUID primary keys, stores metadata, and wraps disk operations for reading, writing, and deleting file contents.
## Creating a File
```php
use Blax\Files\Models\File;
$file = new File;
$file->name = 'report';
$file->extension = 'pdf';
$file->save();
```
On save, the model automatically sets:
- `disk` — from `config('files.disk')` (default: `local`)
- `relativepath` — generated from the `storage_path` template (default: `files/{date}/{uuid}`)
- `user_id` — from the authenticated user, if available
## Writing Content
### From a string
```php
$file->putContents('Hello, world!');
```
### From a local file path
```php
$file->putContentsFromPath('/tmp/export.csv');
```
### From a URL
```php
$file->putContentsFromUrl('https://example.com/data.json');
```
### From an `UploadedFile`
```php
$file->putContentsFromUpload($request->file('document'));
```
This also sets `name`, `extension`, and `type` from the upload metadata if they aren't already set.
All `putContents*` methods auto-detect `extension`, `type` (MIME), and `size`, then persist the model.
---
## Reading Content
```php
$contents = $file->getContents(); // raw string or null
$exists = $file->hasContents(); // bool
```
---
## Deleting Content
```php
$file->deleteContents(); // removes file from disk (and resized variants)
$file->delete(); // deletes the model + contents + pivot entries
```
When a `File` model is deleted, the `deleting` event automatically calls `deleteContents()` and removes all `filables` pivot rows.
---
## Serving Files
### Inline Response
```php
return $file->respond(); // serves the file inline
return $file->respond($request); // uses request params for resizing
```
When the request includes a `size` parameter and the file is an image, `respond()` automatically serves a resized variant. See [Image Optimization](image-optimization.md).
### Download Response
```php
return $file->download(); // downloads as "name.ext"
return $file->download('custom-name.pdf'); // custom filename
```
---
## Duplicating a File
```php
$copy = $file->duplicate(); // "report (copy)"
$copy = $file->duplicate('report-backup'); // custom name
```
Creates a new `File` record with a new UUID and copies the disk contents. The copy is independent — changing one does not affect the other.
---
## Checking Image Status
```php
$file->isImage(); // true if MIME starts with "image" or extension is an image type
```
Recognized image extensions: `jpg`, `jpeg`, `png`, `gif`, `webp`, `svg`, `bmp`, `ico`.
---
## Accessors
| Accessor | Returns | Example |
|---------------------|----------------------|--------------------------------------|
| `$file->path` | Absolute local path | `/storage/files/2024/06/15/abc-123` |
| `$file->url` | Public warehouse URL | `https://app.test/warehouse/abc-123` |
| `$file->size_human` | Human-readable size | `2.4 MB` |
`url` and `size_human` are also included when the model is serialized via `toArray()` or `toJson()`.
---
## Scopes
```php
// Only images
File::images()->get();
// By extension
File::byExtension('pdf', 'docx')->get();
// By disk
File::byDisk('s3')->get();
// Orphaned files (not attached to any model)
File::orphaned()->get();
// Recent files (last N days, default 7)
File::recent()->get();
File::recent(30)->get();
```
---
## Metadata
The `meta` column is cast to JSON. Use it for arbitrary structured data:
```php
$file->meta = ['source' => 'import', 'batch_id' => 42];
$file->save();
$file->meta['source']; // 'import'
```
---
## Model Attributes
| Column | Type | Description |
|--------------------|----------------|------------------------------------------------|
| `id` | UUID string | Primary key (auto-generated) |
| `user_id` | string\|null | Owner (auto-filled from auth) |
| `name` | string\|null | Display name (without extension) |
| `extension` | string\|null | File extension (`pdf`, `jpg`, …) |
| `type` | string\|null | MIME type (`application/pdf`, `image/jpeg`, …) |
| `size` | int\|null | Size in bytes |
| `disk` | string | Filesystem disk name |
| `relativepath` | string | Relative path on the disk |
| `meta` | json\|null | Arbitrary metadata |
| `last_accessed_at` | datetime\|null | Tracking field |
| `created_at` | datetime | |
| `updated_at` | datetime | |
---
Next: [Uploading Files](uploading.md)

162
docs/image-optimization.md Normal file
View File

@ -0,0 +1,162 @@
# Image Optimization
The package provides on-the-fly image resizing with caching, WebP conversion, and configurable quality. Requires `spatie/image`.
## Requirements
```bash
composer require spatie/image "^3.8"
```
If `spatie/image` is not installed, all resizing calls gracefully return the original file path.
---
## Resizing via URL
When serving images through the [Warehouse](serving-files.md), append query parameters:
```
/warehouse/{id}?size=300x200
/warehouse/{id}?size=400&quality=90
/warehouse/{id}?size=800x600&webp=false&position=contain
```
### Query Parameters
| Param | Type | Default | Description |
|------------|----------|---------|---------------------------------------------------------------------------------------------|
| `size` | `string` | — | Dimensions as `WIDTHxHEIGHT` (e.g. `300x200`). Use a single number for square (e.g. `300`). |
| `quality` | `int` | `85` | JPEG/WebP quality (1100) |
| `webp` | `bool` | `true` | Convert output to WebP |
| `position` | `string` | `cover` | Fit mode: `cover`, `contain`, `fill`, `max`, `stretch` |
| `cached` | `bool` | `true` | Use cached resize if available |
| `rounding` | `bool` | `true` | Round dimensions to nearest step |
---
## Resizing Programmatically
Use the `resizedPath()` method to generate a resized variant:
```php
$path = $file->resizedPath(
width: 300,
height: 200,
toWebp: true,
quality: 85,
position: 'cover',
);
```
### Parameters
| Parameter | Type | Default | Description |
|-------------|---------------------|-----------|-----------------------------------------------|
| `$width` | `string\|int\|null` | — | Target width. Use `'auto'` for proportional. |
| `$height` | `string\|int\|null` | — | Target height. Use `'auto'` for proportional. |
| `$rounding` | `bool` | `true` | Round to nearest step (default: 50px) |
| `$toWebp` | `bool` | `true` | Convert to WebP format |
| `$cached` | `bool` | `true` | Return cached version if available |
| `$quality` | `?int` | `null` | Quality (1100). `null` uses config default. |
| `$position` | `string` | `'cover'` | Fit mode |
The method returns an absolute file path to the resized image.
---
## Fit Modes
| Mode | Behavior |
|-----------|------------------------------------------------|
| `cover` | Crop to fill exact dimensions (default) |
| `contain` | Fit within dimensions, preserving aspect ratio |
| `fill` | Fill dimensions, padding if necessary |
| `max` | Resize within dimensions, no upscaling |
| `stretch` | Stretch to exact dimensions |
---
## Caching
Resized images are cached on disk in a `resized/` subdirectory next to the original:
```
files/2024/06/15/abc-123 ← original
files/2024/06/15/resized/
abc-123.300x200.jpg.webp ← resized variant
abc-123.800x600.contain.q90.jpg ← another variant
```
Cache key components: `{width}x{height}`, `.{position}` (if not cover), `.q{quality}` (if not default). Requesting the same size again serves from cache instantly.
When a file is deleted, all resized variants are cleaned up automatically.
---
## Dimension Rounding
To reduce cache fragmentation, dimensions are rounded **up** to the nearest step (default: 50px):
| Requested | Rounded to |
|-----------|------------|
| `280x180` | `300x200` |
| `310x310` | `350x350` |
| `50x50` | `50x50` |
Configure the step size:
```php
// config/files.php
'optimization' => [
'round_to' => 100, // round to nearest 100px
],
```
Disable rounding per request with `?rounding=false` or `rounding: false`.
---
## Skipped Formats
Some formats cannot be processed by image libraries and are served as-is:
```php
'optimization' => [
'skip_formats' => ['gif', 'svg', 'svg+xml'],
],
```
---
## Preferred Extensions (Asset Auto-Resolution)
When the warehouse receives a path without an extension (e.g. `/warehouse/images/logo`), it tries these extensions in order:
```php
'optimization' => [
'preferred_extensions' => ['svg', 'webp', 'png', 'jpg', 'jpeg'],
],
```
---
## Configuration Reference
All optimization settings in `config/files.php`:
```php
'optimization' => [
'enabled' => true,
'default_quality' => 85, // default JPEG/WebP quality
'webp_conversion' => true, // convert to WebP by default
'round_dimensions' => true, // round sizes to reduce cache variants
'round_to' => 50, // rounding step in pixels
'skip_formats' => ['gif', 'svg', 'svg+xml'],
'preferred_extensions' => ['svg', 'webp', 'png', 'jpg', 'jpeg'],
],
```
---
Next: [Configuration](configuration.md)

80
docs/installation.md Normal file
View File

@ -0,0 +1,80 @@
# Installation
## Requirements
- PHP 8.1+
- Laravel 9, 10, 11, or 12
## Install via Composer
```bash
composer require blax-software/laravel-files
```
The service provider is auto-discovered. No manual registration needed.
## Publish Config
```bash
php artisan vendor:publish --tag=files-config
```
This creates `config/files.php` where you can customize models, table names, disk, storage paths, upload limits, optimization settings, and more. See the [Configuration Reference](configuration.md) for details.
## Publish & Run Migrations
```bash
php artisan vendor:publish --tag=files-migrations
php artisan migrate
```
This creates two tables:
| Table | Purpose |
|------------|----------------------------------------------------------------------------------------|
| `files` | Stores file metadata (UUID primary key, name, extension, type, size, disk, path, meta) |
| `filables` | Polymorphic pivot — links files to any model with a role (`as`), order, and meta |
## Environment Variables
Add to your `.env` if you want to change the storage disk:
```env
FILES_DISK=s3
```
By default, files are stored on the `local` disk.
## Optional: Image Optimization
To enable automatic image resizing and WebP conversion:
```bash
composer require spatie/image "^3.8"
```
No further configuration needed — the package detects `spatie/image` at runtime.
## Optional: Access Control
To protect files behind role-based access checks, install [laravel-roles](https://github.com/blax-software/laravel-roles) and enable it in `config/files.php`:
```php
'access_control' => [
'enabled' => true,
],
```
## Optional: WebSocket Progress
For real-time chunk upload progress, install [laravel-websockets](https://github.com/blax-software/laravel-websockets):
```bash
composer require blax-software/laravel-websockets
```
The `ChunkUploadProgress` event is broadcast automatically when websockets are available.
---
Next: [Attaching Files](attaching-files.md)

145
docs/serving-files.md Normal file
View File

@ -0,0 +1,145 @@
# Serving Files (Warehouse)
The **Warehouse** is a public-facing route that resolves file identifiers and serves the contents. It acts as a unified file-serving gateway.
## Warehouse Route
```
GET /warehouse/{identifier?}
```
**Middleware:** `web` (configurable)
The identifier can be:
| Format | Example |
|--------------|-------------------------------------------------|
| UUID | `9c3a7b2e-...` |
| Encrypted ID | `eyJpdiI6...` (legacy support) |
| Asset path | `images/logo` (auto-tries preferred extensions) |
| Storage path | `files/2024/06/15/abc-123` |
### Resolution Order
The `WarehouseService::searchFile()` method tries each strategy in order:
1. **UUID** — direct `File::find($identifier)`
2. **Encrypted ID**`decrypt()``File::find()`
3. **Asset path** — check if file exists on disk; if no extension is provided, try `svg`, `webp`, `png`, `jpg`, `jpeg` in order
4. **Raw storage path** — strip `storage/` prefix and check disk
The first match wins. If nothing is found, a `404` is returned.
### Example URLs
```
https://app.test/warehouse/9c3a7b2e-1234-5678-abcd-ef0123456789
https://app.test/warehouse/images/logo
https://app.test/warehouse/images/logo?size=200x200
```
---
## Serving Files Programmatically
### Inline Response
```php
return $file->respond(); // serves inline with correct content type
return $file->respond($request); // enables image resizing via query params
```
### Download Response
```php
return $file->download(); // "filename.ext"
return $file->download('custom-name.pdf'); // override download name
```
### Generating URLs
```php
use Blax\Files\Services\WarehouseService;
// From a File model
$url = $file->url; // https://app.test/warehouse/{uuid}
// From a service
$url = WarehouseService::url($file); // same as above
$url = WarehouseService::url($fileId); // pass UUID string
```
---
## Image Resizing via Query Params
When serving images through the warehouse, you can request resized variants:
```
/warehouse/{id}?size=300x200
/warehouse/{id}?size=400x400&quality=90&webp=true
/warehouse/{id}?size=800x600&position=contain
```
See [Image Optimization](image-optimization.md) for all parameters.
---
## Access Control
By default, all files are served publicly. To restrict access:
### 1. Enable in config
```php
// config/files.php
'access_control' => [
'enabled' => true,
],
```
### 2. Install laravel-roles
```bash
composer require blax-software/laravel-roles
```
### 3. Behavior
When enabled, the `WarehouseController`:
1. Checks if the request user is authenticated (403 if not)
2. Calls `$user->hasAccess($file)` via the `HasAccess` trait from laravel-roles (403 if denied)
3. Serves the file if access is granted
Access control only applies to persisted `File` records. Non-persisted asset-path lookups bypass the check.
---
## Disabling the Warehouse
```php
// config/files.php
'warehouse' => [
'enabled' => false,
],
```
When disabled, no warehouse routes are registered. You can still serve files manually using `$file->respond()` or `$file->download()`.
---
## Customizing the Route
```php
// config/files.php
'warehouse' => [
'enabled' => true,
'prefix' => 'files', // changes URL to /files/{id}
'middleware' => ['web', 'auth'], // add authentication
],
```
---
Next: [Image Optimization](image-optimization.md)

198
docs/uploading.md Normal file
View File

@ -0,0 +1,198 @@
# Uploading Files
The package provides a complete upload API with support for single-file uploads and chunked uploads for large files.
## Single File Upload
### Endpoint
```
POST /api/files/upload
```
**Middleware:** `api`, `auth:sanctum` (configurable)
### Request
Send a multipart form upload with a `file` field:
```bash
curl -X POST https://app.test/api/files/upload \
-H "Authorization: Bearer $TOKEN" \
-F "file=@photo.jpg"
```
### Validation
- **Max size:** 50 MB (configurable via `files.upload.max_size`)
- **Allowed MIME types:** all by default; restrict via `files.upload.allowed_mimes`
```php
// config/files.php
'upload' => [
'max_size' => 50 * 1024, // KB
'allowed_mimes' => ['jpg', 'png', 'pdf', 'docx'], // empty = allow all
],
```
### Response (201)
```json
{
"id": "9c3a...",
"name": "photo",
"type": "image/jpeg",
"extension": "jpg",
"size": 245760,
"size_human": "240 KB",
"url": "https://app.test/warehouse/9c3a..."
}
```
---
## Chunked Upload
For large files that exceed browser or server limits, use the chunked upload flow.
### Step 1: Initialize
```
POST /api/files/chunk/init
```
**Body (JSON):**
```json
{
"filename": "video.mp4",
"filesize": 104857600,
"total_chunks": 100,
"mime_type": "video/mp4",
"extension": "mp4"
}
```
**Response (201):**
```json
{
"upload_id": "9c3a...",
"file_id": "9c3a...",
"total_chunks": 100
}
```
### Step 2: Upload Chunks
```
POST /api/files/chunk/upload
```
Send each chunk as a multipart upload or raw body:
```bash
curl -X POST https://app.test/api/files/chunk/upload \
-H "Authorization: Bearer $TOKEN" \
-F "upload_id=9c3a..." \
-F "chunk_index=0" \
-F "chunk=@chunk_0.bin"
```
**Response:**
```json
{
"upload_id": "9c3a...",
"chunk_index": 0,
"received": 1,
"total_chunks": 100,
"complete": false
}
```
When the last chunk is received (`complete: true`), all chunks are automatically assembled into the final file.
### Step 3: Done
No finalization call needed. The file is ready to use once `complete` is `true`. The temporary chunk files are cleaned up automatically.
---
## Upload Sessions
Chunk upload sessions are stored in the application cache and expire after **24 hours**. If a session expires before all chunks are received, the upload must be restarted.
---
## Real-Time Progress
If [laravel-websockets](https://github.com/blax-software/laravel-websockets) is installed, a `ChunkUploadProgress` event is broadcast after each chunk:
```php
// Event payload
[
'uploadId' => '9c3a...',
'chunkIndex' => 42,
'totalChunks' => 100,
'complete' => false,
]
```
Listen on the client side to show upload progress bars.
---
## Upload via the HasFiles Trait
When working with models that use `HasFiles`, you can upload and attach in a single call:
```php
// From a form upload
$file = $product->uploadFile(
$request->file('photo'),
as: FileLinkType::Gallery,
);
// From raw content
$file = $product->uploadFileFromContents(
contents: $csvData,
name: 'export',
extension: 'csv',
as: FileLinkType::Document,
);
// From a URL
$file = $product->uploadFileFromUrl(
url: 'https://cdn.example.com/image.jpg',
as: FileLinkType::Banner,
replace: true,
);
```
See [Attaching Files](attaching-files.md#upload-helpers) for full parameter details.
---
## Route Configuration
Customize upload route prefix and middleware in `config/files.php`:
```php
'upload' => [
'route_prefix' => 'api/files',
'middleware' => ['api', 'auth:sanctum'],
],
```
**Routes registered:**
| Method | URI | Name |
|--------|-------------------------|----------------------|
| `POST` | `{prefix}/upload` | `files.upload` |
| `POST` | `{prefix}/chunk/init` | `files.chunk.init` |
| `POST` | `{prefix}/chunk/upload` | `files.chunk.upload` |
---
Next: [Serving Files](serving-files.md)

17
phpunit.xml Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

3
pint.json Normal file
View File

@ -0,0 +1,3 @@
{
"preset": "laravel"
}

32
routes/files.php Normal file
View File

@ -0,0 +1,32 @@
<?php
use Blax\Files\Http\Controllers\FileUploadController;
use Blax\Files\Http\Controllers\WarehouseController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Warehouse (file serving)
|--------------------------------------------------------------------------
*/
if (config('files.warehouse.enabled', true)) {
Route::middleware(config('files.warehouse.middleware', ['web']))
->get(config('files.warehouse.prefix', 'warehouse') . '/{identifier?}', WarehouseController::class)
->name('files.warehouse')
->where('identifier', '[\/\w\.\-\=&@]*');
}
/*
|--------------------------------------------------------------------------
| File Upload API
|--------------------------------------------------------------------------
*/
Route::prefix(config('files.upload.route_prefix', 'api/files'))
->middleware(config('files.upload.middleware', ['api', 'auth:sanctum']))
->group(function () {
Route::post('upload', [FileUploadController::class, 'upload'])->name('files.upload');
Route::post('chunk/init', [FileUploadController::class, 'chunkInit'])->name('files.chunk.init');
Route::post('chunk/upload', [FileUploadController::class, 'chunkUpload'])->name('files.chunk.upload');
});

View File

@ -0,0 +1,44 @@
<?php
namespace Blax\Files\Console;
use Blax\Files\Models\File;
use Illuminate\Console\Command;
class CleanupOrphanedFilesCommand extends Command
{
protected $signature = 'files:cleanup
{--days=30 : Remove orphaned files older than N days}
{--dry-run : Show what would be deleted without deleting}';
protected $description = 'Remove files that are not attached to any model';
public function handle(): int
{
$days = (int) $this->option('days');
$dryRun = $this->option('dry-run');
$query = File::orphaned()
->where('created_at', '<', now()->subDays($days));
$count = $query->count();
if ($count === 0) {
$this->info('No orphaned files found.');
return self::SUCCESS;
}
$this->info(($dryRun ? '[DRY RUN] Would delete' : 'Deleting') . " {$count} orphaned file(s) older than {$days} days…");
if (! $dryRun) {
$query->each(function (File $file) {
$file->delete();
});
$this->info("Deleted {$count} file(s).");
}
return self::SUCCESS;
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace Blax\Files\Enums;
enum FileLinkType: string
{
// Visual Identity
case Avatar = 'avatar';
case ProfileImage = 'profile_image';
case CoverImage = 'cover_image';
case Banner = 'banner';
case Background = 'background';
case Logo = 'logo';
case Icon = 'icon';
case Thumbnail = 'thumbnail';
// Documents
case Document = 'document';
case Invoice = 'invoice';
case Contract = 'contract';
case Certificate = 'certificate';
case Report = 'report';
// Media
case Gallery = 'gallery';
case Video = 'video';
case Audio = 'audio';
// Attachments
case Attachment = 'attachment';
case Download = 'download';
// Catch-All
case Other = 'other';
public function label(): string
{
return match ($this) {
self::Avatar => 'Avatar',
self::ProfileImage => 'Profile Image',
self::CoverImage => 'Cover Image',
self::Banner => 'Banner',
self::Background => 'Background',
self::Logo => 'Logo',
self::Icon => 'Icon',
self::Thumbnail => 'Thumbnail',
self::Document => 'Document',
self::Invoice => 'Invoice',
self::Contract => 'Contract',
self::Certificate => 'Certificate',
self::Report => 'Report',
self::Gallery => 'Gallery',
self::Video => 'Video',
self::Audio => 'Audio',
self::Attachment => 'Attachment',
self::Download => 'Download',
self::Other => 'Other',
};
}
public function isImage(): bool
{
return in_array($this, [
self::Avatar,
self::ProfileImage,
self::CoverImage,
self::Banner,
self::Background,
self::Logo,
self::Icon,
self::Thumbnail,
self::Gallery,
]);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace Blax\Files\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ChunkUploadProgress implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public string $uploadId,
public int $chunkIndex,
public int $totalChunks,
public bool $complete,
) {}
public function broadcastOn(): array
{
return [new Channel("chunk-upload.{$this->uploadId}")];
}
public function broadcastAs(): string
{
return 'chunk.progress';
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace Blax\Files;
class FilesServiceProvider extends \Illuminate\Support\ServiceProvider
{
public function register()
{
$this->mergeConfigFrom(
__DIR__ . '/../config/files.php',
'files',
);
}
public function boot()
{
$this->offerPublishing();
$this->registerModelBindings();
$this->registerRoutes();
$this->registerCommands();
}
protected function offerPublishing()
{
if (! $this->app->runningInConsole()) {
return;
}
$this->publishes([
__DIR__ . '/../config/files.php' => $this->app->configPath('files.php'),
], 'files-config');
$this->publishes([
__DIR__ . '/../database/migrations/create_blax_files_table.php.stub' => $this->getMigrationFileName('create_blax_files_table.php'),
__DIR__ . '/../database/migrations/create_blax_filables_table.php.stub' => $this->getMigrationFileName('create_blax_filables_table.php'),
], 'files-migrations');
}
protected function getMigrationFileName(string $migrationFileName): string
{
$timestamp = date('Y_m_d_His');
$filesystem = $this->app->make(\Illuminate\Filesystem\Filesystem::class);
return \Illuminate\Support\Collection::make([$this->app->databasePath() . DIRECTORY_SEPARATOR . 'migrations' . DIRECTORY_SEPARATOR])
->flatMap(fn($path) => $filesystem->glob($path . '*_' . $migrationFileName))
->push($this->app->databasePath() . "/migrations/{$timestamp}_{$migrationFileName}")
->first();
}
protected function registerModelBindings(): void
{
$fileModel = $this->app->config['files.models.file'] ?? Models\File::class;
$filableModel = $this->app->config['files.models.filable'] ?? Models\Filable::class;
if ($fileModel !== Models\File::class) {
$this->app->bind(Models\File::class, $fileModel);
}
if ($filableModel !== Models\Filable::class) {
$this->app->bind(Models\Filable::class, $filableModel);
}
}
protected function registerRoutes(): void
{
$this->loadRoutesFrom(__DIR__ . '/../routes/files.php');
}
protected function registerCommands(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
Console\CleanupOrphanedFilesCommand::class,
]);
}
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace Blax\Files\Http\Controllers;
use Blax\Files\Models\File;
use Blax\Files\Services\ChunkUploadService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class FileUploadController extends Controller
{
/**
* Standard single-file upload.
*/
public function upload(Request $request): JsonResponse
{
$rules = [
'file' => 'required|file|max:' . config('files.upload.max_size', 51200),
];
$allowedMimes = config('files.upload.allowed_mimes', []);
if (! empty($allowedMimes)) {
$rules['file'] .= '|mimes:' . implode(',', $allowedMimes);
}
$request->validate($rules);
$uploaded = $request->file('file');
$fileModel = config('files.models.file', File::class);
$file = new $fileModel;
$file->save();
$file->putContentsFromUpload($uploaded);
return response()->json([
'id' => $file->id,
'name' => $file->name,
'type' => $file->type,
'extension' => $file->extension,
'size' => $file->size,
'size_human' => $file->size_human,
'url' => $file->url,
], 201);
}
/**
* Initialize a chunked upload.
*/
public function chunkInit(Request $request): JsonResponse
{
$request->validate([
'filename' => 'required|string',
'filesize' => 'required|integer',
'total_chunks' => 'required|integer|min:1',
'mime_type' => 'nullable|string',
'extension' => 'nullable|string',
]);
$result = ChunkUploadService::initialize($request);
return response()->json($result, 201);
}
/**
* Receive a chunk.
*/
public function chunkUpload(Request $request): JsonResponse
{
$request->validate([
'upload_id' => 'required|string',
'chunk_index' => 'required|integer|min:0',
]);
$result = ChunkUploadService::receiveChunk($request);
return response()->json($result);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace Blax\Files\Http\Controllers;
use Blax\Files\Models\File;
use Blax\Files\Services\WarehouseService;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class WarehouseController extends Controller
{
public function __invoke(Request $request, ?string $identifier = null)
{
$identifier ??= $request->get('id');
$file = WarehouseService::searchFile($request, $identifier);
if (! $file) {
abort(404);
}
// Access control check (optional, via laravel-roles)
if (config('files.access_control.enabled') && $file->exists) {
$this->checkAccess($request, $file);
}
return $file->respond($request);
}
protected function checkAccess(Request $request, File $file): void
{
// If laravel-roles is installed, check HasAccess
if (
trait_exists(\Blax\Roles\Traits\HasAccess::class)
&& method_exists($file, 'hasAccess')
) {
$user = $request->user();
if (! $user) {
abort(403, 'Authentication required.');
}
if (! $user->hasAccess($file)) {
abort(403, 'Access denied.');
}
}
}
}

105
src/Models/Filable.php Normal file
View File

@ -0,0 +1,105 @@
<?php
namespace Blax\Files\Models;
use Blax\Files\Enums\FileLinkType;
use Illuminate\Database\Eloquent\Relations\MorphPivot;
class Filable extends MorphPivot
{
protected $fillable = [
'file_id',
'filable_id',
'filable_type',
'as',
'order',
'meta',
];
protected $casts = [
'meta' => 'json',
'order' => 'integer',
];
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->table = config('files.table_names.filables') ?: 'filables';
}
/*
|--------------------------------------------------------------------------
| Relationships
|--------------------------------------------------------------------------
*/
public function file()
{
return $this->belongsTo(config('files.models.file', File::class));
}
public function filable()
{
return $this->morphTo();
}
/*
|--------------------------------------------------------------------------
| Scopes
|--------------------------------------------------------------------------
*/
public function scopeOrdered($query)
{
return $query->orderByRaw('CASE WHEN "order" IS NULL THEN 1 ELSE 0 END, "order" ASC');
}
public function scopeAs($query, string|FileLinkType $type)
{
$value = $type instanceof FileLinkType ? $type->value : $type;
return $query->where('as', $value);
}
public static function boot()
{
parent::boot();
static::addGlobalScope('ordered', function ($query) {
$query->ordered();
});
}
/*
|--------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------
*/
public function setAs(string|FileLinkType $value, bool $save = true): static
{
$this->as = $value instanceof FileLinkType ? $value->value : $value;
if ($save) {
$this->save();
}
return $this;
}
public function setOrder(int $value, bool $save = true): static
{
$this->order = $value;
if ($save) {
$this->save();
}
return $this;
}
public function getLinkType(): ?FileLinkType
{
return FileLinkType::tryFrom($this->as);
}
}

453
src/Models/File.php Normal file
View File

@ -0,0 +1,453 @@
<?php
namespace Blax\Files\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class File extends Model
{
use HasUuids;
protected $fillable = [
'user_id',
'name',
'extension',
'type',
'size',
'disk',
'relativepath',
'meta',
];
protected $casts = [
'id' => 'string',
'meta' => 'json',
'last_accessed_at' => 'datetime',
];
public $incrementing = false;
protected $keyType = 'string';
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->table = config('files.table_names.files') ?: parent::getTable();
}
/*
|--------------------------------------------------------------------------
| Boot
|--------------------------------------------------------------------------
*/
public static function booted()
{
static::saving(function (self $file) {
$file->disk ??= config('files.disk', 'local');
if (! $file->relativepath) {
$file->relativepath = static::buildStoragePath($file);
}
$file->user_id ??= optional(optional(auth())->user())->id;
});
}
protected static function buildStoragePath(self $file): string
{
$template = config('files.storage_path', 'files/{date}/{uuid}');
return str_replace(
['{user_id}', '{uuid}', '{date}'],
[
optional(optional(auth())->user())->id ?? 'anonymous',
$file->id ?? (string) \Illuminate\Support\Str::uuid(),
now()->format('Y/m/d'),
],
$template,
);
}
/*
|--------------------------------------------------------------------------
| Relationships
|--------------------------------------------------------------------------
*/
public function user()
{
return $this->belongsTo(
config('auth.providers.users.model', 'App\\Models\\User'),
'user_id',
);
}
public function filables()
{
return $this->hasMany(config('files.models.filable', Filable::class));
}
/*
|--------------------------------------------------------------------------
| Scopes
|--------------------------------------------------------------------------
*/
public function scopeImages($query)
{
return $query->where(function ($q) {
$q->where('type', 'like', 'image/%')
->orWhereIn('extension', ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico']);
});
}
public function scopeByExtension($query, string ...$extensions)
{
return $query->whereIn('extension', $extensions);
}
public function scopeByDisk($query, string $disk)
{
return $query->where('disk', $disk);
}
public function scopeOrphaned($query)
{
return $query->whereDoesntHave('filables');
}
public function scopeRecent($query, int $days = 7)
{
return $query->where('created_at', '>=', now()->subDays($days));
}
/*
|--------------------------------------------------------------------------
| Accessors
|--------------------------------------------------------------------------
*/
public function getSizeHumanAttribute(): string
{
$bytes = $this->size ?? 0;
if ($bytes >= 1073741824) {
return round($bytes / 1073741824, 1) . ' GB';
} elseif ($bytes >= 1048576) {
return round($bytes / 1048576, 1) . ' MB';
} elseif ($bytes >= 1024) {
return round($bytes / 1024, 1) . ' KB';
}
return $bytes . ' B';
}
public function getPathAttribute(): string
{
return Storage::disk($this->disk)->path($this->relativepath);
}
public function getUrlAttribute(): string
{
$prefix = config('files.warehouse.prefix', 'warehouse');
return url("{$prefix}/{$this->id}");
}
/*
|--------------------------------------------------------------------------
| File Content Operations
|--------------------------------------------------------------------------
*/
public function putContents(string $contents): static
{
Storage::disk($this->disk)->put($this->relativepath, $contents);
if (! $this->extension) {
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->buffer($contents);
$this->extension = explode('/', $mimeType)[1] ?? null;
}
if (! $this->type) {
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$this->type = $finfo->buffer($contents);
}
$this->size = Storage::disk($this->disk)->size($this->relativepath);
$this->save();
return $this;
}
public function putContentsFromPath(string $absolutePath): static
{
return $this->putContents(file_get_contents($absolutePath));
}
public function putContentsFromUrl(string $url): static
{
return $this->putContents(file_get_contents($url));
}
public function putContentsFromUpload(\Illuminate\Http\UploadedFile $upload): static
{
$this->name ??= pathinfo($upload->getClientOriginalName(), PATHINFO_FILENAME);
$this->extension ??= $upload->getClientOriginalExtension();
$this->type ??= $upload->getMimeType();
$this->save();
return $this->putContents($upload->getContent());
}
public function getContents(): ?string
{
return Storage::disk($this->disk)->get($this->relativepath);
}
public function hasContents(): bool
{
return Storage::disk($this->disk)->exists($this->relativepath);
}
public function deleteContents(): static
{
Storage::disk($this->disk)->delete($this->relativepath);
// Remove resized variants
$dir = pathinfo($this->path, PATHINFO_DIRNAME);
$resizedDir = $dir . '/resized';
if (is_dir($resizedDir)) {
$files = glob($resizedDir . '/' . basename($this->relativepath) . '*');
foreach ($files as $f) {
@unlink($f);
}
}
return $this;
}
/*
|--------------------------------------------------------------------------
| Response / Serving
|--------------------------------------------------------------------------
*/
public function respond(?\Illuminate\Http\Request $request = null): \Symfony\Component\HttpFoundation\Response
{
$request ??= request();
// If a size is requested and optimization is available, serve resized
if ($request->has('size') && $this->isImage()) {
$path = $this->resolveResizedPath($request);
} else {
$path = $this->path;
}
if (! file_exists($path)) {
abort(404);
}
return response()->file($path);
}
public function download(?string $filename = null): \Symfony\Component\HttpFoundation\BinaryFileResponse
{
$path = $this->path;
if (! file_exists($path)) {
abort(404);
}
$name = $filename ?? ($this->name . '.' . $this->extension);
return response()->download($path, $name);
}
public function isImage(): bool
{
if ($this->type && str_starts_with($this->type, 'image')) {
return true;
}
$imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'];
return in_array(strtolower($this->extension ?? ''), $imageExtensions);
}
/*
|--------------------------------------------------------------------------
| Image Optimization / Resizing
|--------------------------------------------------------------------------
*/
public function resolveResizedPath(\Illuminate\Http\Request $request): string
{
if (! class_exists(\Spatie\Image\Image::class)) {
return $this->path;
}
$config = config('files.optimization', []);
$skipFormats = $config['skip_formats'] ?? ['gif', 'svg', 'svg+xml'];
$ext = strtolower($this->extension ?? '');
// Skip non-optimizable formats
if (in_array($ext, $skipFormats) || str_contains($ext, 'svg')) {
return $this->path;
}
$size = $request->get('size', '');
$parts = explode('x', $size);
$width = $parts[0] ?? null;
$height = $parts[1] ?? $width;
$quality = $request->has('quality') ? (int) $request->get('quality') : ($config['default_quality'] ?? 85);
$webp = filter_var($request->get('webp', $config['webp_conversion'] ?? true), FILTER_VALIDATE_BOOLEAN);
$cached = filter_var($request->get('cached', true), FILTER_VALIDATE_BOOLEAN);
$rounding = filter_var($request->get('rounding', $config['round_dimensions'] ?? true), FILTER_VALIDATE_BOOLEAN);
$position = $request->get('position', 'cover');
return $this->resizedPath(
$width,
$height,
rounding: $rounding,
toWebp: $webp,
cached: $cached,
quality: $quality,
position: $position,
);
}
public function resizedPath(
string|int|null $width,
string|int|null $height,
bool $rounding = true,
bool $toWebp = true,
bool $cached = true,
?int $quality = null,
string $position = 'cover',
): string {
$path = $this->path;
$ext = strtolower($this->extension ?? pathinfo($path, PATHINFO_EXTENSION));
// Normalize dimensions
if ($width !== null && strtolower((string) $width) !== 'auto') {
$width = max(1, (int) $width);
}
if ($height !== null && strtolower((string) $height) !== 'auto') {
$height = max(1, (int) $height);
}
$width = $width ?: $height;
$height = $height ?: $width;
// Round to nearest step
$roundTo = config('files.optimization.round_to', 50);
if ($rounding) {
$width = ($width === 'auto') ? $width : (int) (ceil((int) $width / $roundTo) * $roundTo);
$height = ($height === 'auto') ? $height : (int) (ceil((int) $height / $roundTo) * $roundTo);
}
// Build cache key
$dir = pathinfo($path, PATHINFO_DIRNAME);
$resizedDir = $dir . '/resized';
if (! is_dir($resizedDir)) {
@mkdir($resizedDir, 0755, true);
}
$cacheKey = $width . 'x' . $height;
if ($position !== 'cover') {
$cacheKey .= '.' . $position;
}
if ($quality !== null && $quality > 0 && $quality < 100) {
$cacheKey .= '.q' . $quality;
}
$cachedPath = $resizedDir . '/' . basename($path, '.' . $ext) . '.' . $cacheKey . '.' . $ext;
if ($toWebp && $this->isImage()) {
$cachedPath .= '.webp';
}
// Return cached version if available
if ($cached && file_exists($cachedPath)) {
return $cachedPath;
}
// Generate resized version
copy($path, $cachedPath);
$fit = match ($position) {
'contain' => \Spatie\Image\Enums\Fit::Contain,
'fill' => \Spatie\Image\Enums\Fit::Fill,
'max' => \Spatie\Image\Enums\Fit::Max,
'stretch' => \Spatie\Image\Enums\Fit::Stretch,
default => \Spatie\Image\Enums\Fit::Crop,
};
$image = \Spatie\Image\Image::load($cachedPath)
->fit(
$fit,
($width === 'auto') ? null : (int) $width,
($height === 'auto') ? null : (int) $height,
);
if ($quality !== null && $quality > 0) {
$image->quality(min(100, max(1, $quality)));
}
$image->save($cachedPath);
return $cachedPath;
}
/*
|--------------------------------------------------------------------------
| Cleanup
|--------------------------------------------------------------------------
*/
protected static function booting()
{
static::deleting(function (self $file) {
$file->deleteContents();
$file->filables()->delete();
});
}
/*
|--------------------------------------------------------------------------
| Duplication
|--------------------------------------------------------------------------
*/
public function duplicate(?string $newName = null): static
{
$clone = $this->replicate(['id', 'relativepath']);
$clone->name = $newName ?? ($this->name . ' (copy)');
$clone->save();
if ($this->hasContents()) {
$clone->putContents($this->getContents());
}
return $clone;
}
/*
|--------------------------------------------------------------------------
| Serialization
|--------------------------------------------------------------------------
*/
public function toArray(): array
{
$array = parent::toArray();
$array['url'] = $this->url;
$array['size_human'] = $this->size_human;
return $array;
}
}

View File

@ -0,0 +1,142 @@
<?php
namespace Blax\Files\Services;
use Blax\Files\Models\File;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ChunkUploadService
{
/**
* Initialize a chunked upload session.
*
* Returns a File model and a temporary upload identifier.
*/
public static function initialize(Request $request): array
{
$fileName = $request->input('filename', 'upload');
$fileSize = $request->input('filesize');
$mimeType = $request->input('mime_type');
$extension = $request->input('extension') ?? pathinfo($fileName, PATHINFO_EXTENSION);
$totalChunks = $request->input('total_chunks', 1);
$fileModel = config('files.models.file', File::class);
$file = new $fileModel;
$file->name = pathinfo($fileName, PATHINFO_FILENAME);
$file->extension = $extension;
$file->type = $mimeType;
$file->size = $fileSize;
$file->save();
// Store upload metadata in cache
$uploadId = $file->id;
cache()->put("chunk_upload:{$uploadId}", [
'file_id' => $file->id,
'total_chunks' => (int) $totalChunks,
'received' => [],
'temp_dir' => "chunk_uploads/{$uploadId}",
], now()->addHours(24));
return [
'upload_id' => $uploadId,
'file_id' => $file->id,
'total_chunks' => (int) $totalChunks,
];
}
/**
* Receive a single chunk.
*/
public static function receiveChunk(Request $request): array
{
$uploadId = $request->input('upload_id');
$chunkIndex = (int) $request->input('chunk_index', 0);
$meta = cache()->get("chunk_upload:{$uploadId}");
if (! $meta) {
abort(404, 'Upload session not found or expired.');
}
$tempDir = $meta['temp_dir'];
$disk = config('files.disk', 'local');
// Accept chunk as file upload or raw body
if ($request->hasFile('chunk')) {
$content = $request->file('chunk')->getContent();
} else {
$content = $request->getContent();
}
Storage::disk($disk)->put("{$tempDir}/{$chunkIndex}", $content);
$meta['received'][] = $chunkIndex;
$meta['received'] = array_unique($meta['received']);
cache()->put("chunk_upload:{$uploadId}", $meta, now()->addHours(24));
$complete = count($meta['received']) >= $meta['total_chunks'];
if ($complete) {
static::assembleChunks($uploadId);
}
// Broadcast progress if websockets available
static::broadcastProgress($uploadId, $chunkIndex, $meta['total_chunks'], $complete);
return [
'upload_id' => $uploadId,
'chunk_index' => $chunkIndex,
'received' => count($meta['received']),
'total_chunks' => $meta['total_chunks'],
'complete' => $complete,
];
}
/**
* Assemble all chunks into the final file.
*/
protected static function assembleChunks(string $uploadId): void
{
$meta = cache()->get("chunk_upload:{$uploadId}");
if (! $meta) {
return;
}
$disk = config('files.disk', 'local');
$file = File::findOrFail($meta['file_id']);
$tempDir = $meta['temp_dir'];
// Concatenate chunks in order
$assembled = '';
for ($i = 0; $i < $meta['total_chunks']; $i++) {
$chunkPath = "{$tempDir}/{$i}";
if (Storage::disk($disk)->exists($chunkPath)) {
$assembled .= Storage::disk($disk)->get($chunkPath);
}
}
$file->putContents($assembled);
// Cleanup temp chunks
for ($i = 0; $i < $meta['total_chunks']; $i++) {
Storage::disk($disk)->delete("{$tempDir}/{$i}");
}
Storage::disk($disk)->deleteDirectory($tempDir);
cache()->forget("chunk_upload:{$uploadId}");
}
/**
* Broadcast upload progress via event (works with or without websockets).
*/
protected static function broadcastProgress(string $uploadId, int $chunkIndex, int $total, bool $complete): void
{
try {
if (class_exists(\Illuminate\Support\Facades\Broadcast::class)) {
event(new \Blax\Files\Events\ChunkUploadProgress($uploadId, $chunkIndex, $total, $complete));
}
} catch (\Throwable $e) {
// Websockets not available — ignore silently
}
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace Blax\Files\Services;
use Blax\Files\Models\File;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class WarehouseService
{
/**
* Search for a file based on the request and identifier.
*/
public static function searchFile(Request $request, ?string $identifier): ?File
{
if (! $identifier) {
return null;
}
// Strip query string if present
if (str_contains($identifier, '?')) {
$identifier = explode('?', $identifier)[0];
}
// 1. Try UUID lookup
$file = static::searchByUuid($identifier);
if ($file) {
return $file;
}
// 2. Try encrypted ID
$file = static::searchByEncryptedId($identifier);
if ($file) {
return $file;
}
// 3. Try as static asset path
$file = static::searchAssetPath($identifier);
if ($file) {
return $file;
}
// 4. Try raw storage path
return static::searchStoragePath($identifier);
}
/**
* Search by UUID (direct File ID).
*/
protected static function searchByUuid(string $identifier): ?File
{
return File::find($identifier);
}
/**
* Search by encrypted (legacy) ID.
*/
protected static function searchByEncryptedId(string $identifier): ?File
{
try {
$decryptedId = decrypt($identifier);
if ($decryptedId) {
return File::find($decryptedId);
}
} catch (\Exception $e) {
// Not an encrypted ID — ignore
}
return null;
}
/**
* Search for a static asset, trying preferred extensions.
*/
protected static function searchAssetPath(string $path): ?File
{
$disk = config('files.disk', 'local');
$extensions = config('files.optimization.preferred_extensions', ['svg', 'webp', 'png', 'jpg', 'jpeg']);
// Try exact path
if (Storage::disk($disk)->exists($path)) {
return static::fileInstanceFromPath($path, $disk);
}
// Try with preferred extensions if no extension detected
if (! pathinfo($path, PATHINFO_EXTENSION)) {
foreach ($extensions as $ext) {
if (Storage::disk($disk)->exists($path . '.' . $ext)) {
return static::fileInstanceFromPath($path . '.' . $ext, $disk);
}
}
}
return null;
}
/**
* Search by raw storage path.
*/
protected static function searchStoragePath(string $path): ?File
{
$disk = config('files.disk', 'local');
$path = str_replace('storage/', '', $path);
if (Storage::disk($disk)->exists($path)) {
return static::fileInstanceFromPath($path, $disk);
}
return null;
}
/**
* Create a non-persisted File model instance for serving a storage path.
*/
protected static function fileInstanceFromPath(string $relativePath, string $disk): File
{
$file = new File;
$file->name = basename($relativePath);
$file->relativepath = $relativePath;
$file->disk = $disk;
$file->extension = pathinfo($relativePath, PATHINFO_EXTENSION);
$file->exists = false; // not persisted
return $file;
}
/**
* Generate the public warehouse URL for a file.
*/
public static function url(File|string $file): string
{
$id = $file instanceof File ? $file->id : $file;
$prefix = config('files.warehouse.prefix', 'warehouse');
return url("{$prefix}/{$id}");
}
}

312
src/Traits/HasFiles.php Normal file
View File

@ -0,0 +1,312 @@
<?php
namespace Blax\Files\Traits;
use Blax\Files\Enums\FileLinkType;
use Blax\Files\Models\Filable;
use Blax\Files\Models\File;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Http\UploadedFile;
trait HasFiles
{
/*
|--------------------------------------------------------------------------
| Relationships
|--------------------------------------------------------------------------
*/
public function files(): MorphToMany
{
return $this->morphToMany(
config('files.models.file', File::class),
'filable',
config('files.table_names.filables', 'filables'),
)
->using(config('files.models.filable', Filable::class))
->withPivot(['id', 'as', 'order', 'meta'])
->withTimestamps();
}
public function getFilePivot(File|string $file): ?Filable
{
$fileId = $file instanceof File ? $file->id : $file;
return $this->files()
->where('file_id', $fileId)
->withPivot(['id', 'as', 'order', 'meta'])
->first()?->pivot;
}
/*
|--------------------------------------------------------------------------
| Query Helpers
|--------------------------------------------------------------------------
*/
/**
* Get files attached with a specific role (e.g. 'avatar', 'thumbnail', ).
* Accepts a string or a FileLinkType enum.
*/
public function filesAs(string|FileLinkType $type): MorphToMany
{
$value = $type instanceof FileLinkType ? $type->value : $type;
return $this->files()->wherePivot('as', $value);
}
/**
* Get the first file attached with a specific role.
*/
public function fileAs(string|FileLinkType $type): ?File
{
return $this->filesAs($type)->first();
}
/**
* Convenience: get profile image / avatar.
*/
public function getAvatar(): ?File
{
return $this->fileAs(FileLinkType::Avatar)
?? $this->fileAs(FileLinkType::ProfileImage);
}
/**
* Convenience: get thumbnail.
*/
public function getThumbnail(): ?File
{
return $this->fileAs(FileLinkType::Thumbnail);
}
/**
* Convenience: get banner.
*/
public function getBanner(): ?File
{
return $this->fileAs(FileLinkType::Banner);
}
/**
* Convenience: get cover image.
*/
public function getCoverImage(): ?File
{
return $this->fileAs(FileLinkType::CoverImage);
}
/**
* Convenience: get background.
*/
public function getBackground(): ?File
{
return $this->fileAs(FileLinkType::Background);
}
/**
* Convenience: get logo.
*/
public function getLogo(): ?File
{
return $this->fileAs(FileLinkType::Logo);
}
/**
* Convenience: get gallery images.
*/
public function getGallery(): \Illuminate\Database\Eloquent\Collection
{
return $this->filesAs(FileLinkType::Gallery)->get();
}
/*
|--------------------------------------------------------------------------
| Attach / Detach
|--------------------------------------------------------------------------
*/
/**
* Attach an existing File to this model with a role.
*
* If $replace is true (default for singular types like avatar), the
* previous attachment with that role is removed first.
*/
public function attachFile(
File|string $file,
string|FileLinkType|null $as = null,
?int $order = null,
?array $meta = null,
bool $replace = false,
): static {
$fileId = $file instanceof File ? $file->id : $file;
$asValue = $as instanceof FileLinkType ? $as->value : $as;
if ($replace && $asValue) {
$this->detachFilesAs($asValue);
}
// Prevent duplicate pivot entries
$existing = $this->files()
->where('file_id', $fileId)
->wherePivot('as', $asValue)
->exists();
if (! $existing) {
$this->files()->attach($fileId, array_filter([
'as' => $asValue,
'order' => $order,
'meta' => $meta ? json_encode($meta) : null,
], fn($v) => $v !== null));
}
return $this;
}
/**
* Detach a specific file from this model.
*/
public function detachFile(File|string $file, ?string $as = null): static
{
$fileId = $file instanceof File ? $file->id : $file;
$query = $this->files()->newPivotQuery()
->where('file_id', $fileId)
->where('filable_type', static::class)
->where('filable_id', $this->getKey());
if ($as !== null) {
$query->where('as', $as);
}
$query->delete();
return $this;
}
/**
* Detach all files with a specific role.
*/
public function detachFilesAs(string|FileLinkType $type): static
{
$value = $type instanceof FileLinkType ? $type->value : $type;
$this->files()->newPivotQuery()
->where('filable_type', static::class)
->where('filable_id', $this->getKey())
->where('as', $value)
->delete();
return $this;
}
/**
* Detach all files from this model.
*/
public function detachAllFiles(): static
{
$this->files()->detach();
return $this;
}
/*
|--------------------------------------------------------------------------
| Upload Helpers
|--------------------------------------------------------------------------
*/
/**
* Upload a file and attach it to this model in one call.
*/
public function uploadFile(
UploadedFile $upload,
string|FileLinkType|null $as = null,
?int $order = null,
?array $meta = null,
bool $replace = false,
): File {
$fileModel = config('files.models.file', File::class);
$file = new $fileModel;
$file->save();
$file->putContentsFromUpload($upload);
$this->attachFile($file, $as, $order, $meta, $replace);
return $file;
}
/**
* Create a file from raw content and attach.
*/
public function uploadFileFromContents(
string $contents,
?string $name = null,
?string $extension = null,
string|FileLinkType|null $as = null,
?int $order = null,
bool $replace = false,
): File {
$fileModel = config('files.models.file', File::class);
$file = new $fileModel;
$file->name = $name;
$file->extension = $extension;
$file->save();
$file->putContents($contents);
$this->attachFile($file, $as, $order, replace: $replace);
return $file;
}
/**
* Download a file from URL and attach.
*/
public function uploadFileFromUrl(
string $url,
?string $name = null,
string|FileLinkType|null $as = null,
?int $order = null,
bool $replace = false,
): File {
$fileModel = config('files.models.file', File::class);
$file = new $fileModel;
$file->name = $name ?? basename(parse_url($url, PHP_URL_PATH));
$file->save();
$file->putContentsFromUrl($url);
$this->attachFile($file, $as, $order, replace: $replace);
return $file;
}
/*
|--------------------------------------------------------------------------
| Reorder
|--------------------------------------------------------------------------
*/
/**
* Reorder files accepts array of file IDs in desired order.
* Optionally scoped to a specific role.
*/
public function reorderFiles(array $fileIds, string|FileLinkType|null $as = null): static
{
$asValue = $as instanceof FileLinkType ? $as->value : $as;
foreach ($fileIds as $index => $fileId) {
$query = $this->files()->newPivotQuery()
->where('file_id', $fileId)
->where('filable_type', static::class)
->where('filable_id', $this->getKey());
if ($asValue !== null) {
$query->where('as', $asValue);
}
$query->update(['order' => $index]);
}
return $this;
}
}

View File

@ -0,0 +1,210 @@
<?php
namespace Blax\Files\Tests\Unit;
use Blax\Files\FilesServiceProvider;
use Blax\Files\Models\File;
use Blax\Files\Services\ChunkUploadService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Orchestra\Testbench\TestCase;
class ChunkUploadServiceTest extends TestCase
{
use RefreshDatabase;
protected function getPackageProviders($app): array
{
return [FilesServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
}
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations');
}
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
}
// ─── initialize ────────────────────────────────────────────────
public function test_initialize_creates_file_and_cache()
{
$request = Request::create('/', 'POST', [
'filename' => 'video.mp4',
'filesize' => 5000000,
'total_chunks' => 5,
'mime_type' => 'video/mp4',
'extension' => 'mp4',
]);
$result = ChunkUploadService::initialize($request);
$this->assertArrayHasKey('upload_id', $result);
$this->assertArrayHasKey('file_id', $result);
$this->assertEquals(5, $result['total_chunks']);
// File should exist in database
$file = File::find($result['file_id']);
$this->assertNotNull($file);
$this->assertEquals('video', $file->name);
$this->assertEquals('mp4', $file->extension);
$this->assertEquals('video/mp4', $file->type);
// Cache should have upload metadata
$meta = cache()->get("chunk_upload:{$result['upload_id']}");
$this->assertNotNull($meta);
$this->assertEquals(5, $meta['total_chunks']);
$this->assertEmpty($meta['received']);
}
public function test_initialize_derives_extension_from_filename()
{
$request = Request::create('/', 'POST', [
'filename' => 'document.pdf',
'filesize' => 1000,
'total_chunks' => 1,
]);
$result = ChunkUploadService::initialize($request);
$file = File::find($result['file_id']);
$this->assertEquals('pdf', $file->extension);
}
// ─── receiveChunk ──────────────────────────────────────────────
public function test_receive_chunk_stores_data()
{
$request = Request::create('/', 'POST', [
'filename' => 'data.bin',
'filesize' => 200,
'total_chunks' => 2,
]);
$init = ChunkUploadService::initialize($request);
// Send first chunk
$chunkRequest = Request::create('/', 'POST', [
'upload_id' => $init['upload_id'],
'chunk_index' => 0,
], [], [], [], 'chunk-0-data');
$result = ChunkUploadService::receiveChunk($chunkRequest);
$this->assertEquals(0, $result['chunk_index']);
$this->assertEquals(1, $result['received']);
$this->assertEquals(2, $result['total_chunks']);
$this->assertFalse($result['complete']);
}
public function test_receive_all_chunks_assembles_file()
{
$request = Request::create('/', 'POST', [
'filename' => 'assembled.txt',
'filesize' => 10,
'total_chunks' => 3,
'mime_type' => 'text/plain',
'extension' => 'txt',
]);
$init = ChunkUploadService::initialize($request);
// 3 chunks
$chunks = ['AAA', 'BBB', 'CCC'];
$lastResult = null;
for ($i = 0; $i < 3; $i++) {
$chunkRequest = Request::create('/', 'POST', [
'upload_id' => $init['upload_id'],
'chunk_index' => $i,
], [], [], [], $chunks[$i]);
$lastResult = ChunkUploadService::receiveChunk($chunkRequest);
}
$this->assertTrue($lastResult['complete']);
// File should have assembled content
$file = File::find($init['file_id']);
$this->assertEquals('AAABBBCCC', $file->getContents());
// Cache should be cleaned up
$this->assertNull(cache()->get("chunk_upload:{$init['upload_id']}"));
}
public function test_receive_chunk_with_expired_session_aborts()
{
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
$chunkRequest = Request::create('/', 'POST', [
'upload_id' => 'non-existent-id',
'chunk_index' => 0,
], [], [], [], 'data');
ChunkUploadService::receiveChunk($chunkRequest);
}
public function test_chunks_received_out_of_order_still_assemble_correctly()
{
$request = Request::create('/', 'POST', [
'filename' => 'shuffled.txt',
'filesize' => 9,
'total_chunks' => 3,
'mime_type' => 'text/plain',
]);
$init = ChunkUploadService::initialize($request);
// Send out of order: 2, 0, 1
$order = [2 => 'CCC', 0 => 'AAA', 1 => 'BBB'];
foreach ($order as $index => $data) {
$chunkRequest = Request::create('/', 'POST', [
'upload_id' => $init['upload_id'],
'chunk_index' => $index,
], [], [], [], $data);
ChunkUploadService::receiveChunk($chunkRequest);
}
$file = File::find($init['file_id']);
// Even though chunks arrived out of order, assembly is index-based
$this->assertEquals('AAABBBCCC', $file->getContents());
}
public function test_duplicate_chunk_index_is_deduplicated()
{
$request = Request::create('/', 'POST', [
'filename' => 'dup.txt',
'filesize' => 3,
'total_chunks' => 1,
'mime_type' => 'text/plain',
]);
$init = ChunkUploadService::initialize($request);
// Send same chunk twice
$chunkRequest = Request::create('/', 'POST', [
'upload_id' => $init['upload_id'],
'chunk_index' => 0,
], [], [], [], 'AAA');
ChunkUploadService::receiveChunk($chunkRequest);
// It should have complete=true after first, so this is just verifying
// second call doesn't break anything
$file = File::find($init['file_id']);
$this->assertNotNull($file->getContents());
}
}

View File

@ -0,0 +1,249 @@
<?php
namespace Blax\Files\Tests\Unit;
use Blax\Files\Enums\FileLinkType;
use Blax\Files\FilesServiceProvider;
use Blax\Files\Models\Filable;
use Blax\Files\Models\File;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Orchestra\Testbench\TestCase;
class FilableModelTest extends TestCase
{
use RefreshDatabase;
protected function getPackageProviders($app): array
{
return [FilesServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
}
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations');
}
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
}
// ─── scopeAs ───────────────────────────────────────────────────
public function test_scope_as_filters_by_role()
{
$file = File::create([
'name' => 'test',
'extension' => 'png',
'type' => 'image/png',
'disk' => 'local',
]);
Filable::create([
'file_id' => $file->id,
'filable_id' => 1,
'filable_type' => 'App\Models\User',
'as' => 'avatar',
'order' => 0,
]);
Filable::create([
'file_id' => $file->id,
'filable_id' => 1,
'filable_type' => 'App\Models\User',
'as' => 'banner',
'order' => 0,
]);
$avatars = Filable::as('avatar')->get();
$this->assertCount(1, $avatars);
$this->assertEquals('avatar', $avatars->first()->as);
}
// ─── set helpers ───────────────────────────────────────────────
public function test_set_as_updates_in_memory()
{
$file = File::create([
'name' => 'test',
'extension' => 'png',
'type' => 'image/png',
'disk' => 'local',
]);
$filable = Filable::create([
'file_id' => $file->id,
'filable_id' => 1,
'filable_type' => 'App\Models\User',
'as' => 'avatar',
'order' => 0,
]);
$filable->setAs('thumbnail', save: false);
$this->assertEquals('thumbnail', $filable->as);
}
public function test_set_order_updates_in_memory()
{
$file = File::create([
'name' => 'test',
'extension' => 'png',
'type' => 'image/png',
'disk' => 'local',
]);
$filable = Filable::create([
'file_id' => $file->id,
'filable_id' => 1,
'filable_type' => 'App\Models\User',
'as' => 'gallery',
'order' => 0,
]);
$filable->setOrder(5, save: false);
$this->assertEquals(5, $filable->order);
}
// ─── getLinkType ───────────────────────────────────────────────
public function test_get_link_type_returns_enum()
{
$file = File::create([
'name' => 'test',
'extension' => 'png',
'type' => 'image/png',
'disk' => 'local',
]);
$filable = Filable::create([
'file_id' => $file->id,
'filable_id' => 1,
'filable_type' => 'App\Models\User',
'as' => 'avatar',
'order' => 0,
]);
$linkType = $filable->getLinkType();
$this->assertEquals(FileLinkType::Avatar, $linkType);
}
public function test_get_link_type_returns_null_for_unknown()
{
$file = File::create([
'name' => 'test',
'extension' => 'png',
'type' => 'image/png',
'disk' => 'local',
]);
$filable = Filable::create([
'file_id' => $file->id,
'filable_id' => 1,
'filable_type' => 'App\Models\User',
'as' => 'something_custom',
'order' => 0,
]);
$this->assertNull($filable->getLinkType());
}
// ─── meta cast ─────────────────────────────────────────────────
public function test_meta_is_json_cast()
{
$file = File::create([
'name' => 'test',
'extension' => 'png',
'type' => 'image/png',
'disk' => 'local',
]);
$filable = Filable::create([
'file_id' => $file->id,
'filable_id' => 1,
'filable_type' => 'App\Models\User',
'as' => 'gallery',
'order' => 0,
'meta' => ['width' => 800, 'height' => 600],
]);
$refreshed = Filable::where('file_id', $file->id)->where('as', 'gallery')->first();
$this->assertIsArray($refreshed->meta);
$this->assertEquals(800, $refreshed->meta['width']);
}
// ─── global scope ordering ─────────────────────────────────────
public function test_default_ordering_by_order_column()
{
$file = File::create([
'name' => 'test',
'extension' => 'png',
'type' => 'image/png',
'disk' => 'local',
]);
Filable::create([
'file_id' => $file->id,
'filable_id' => 1,
'filable_type' => 'App\Models\User',
'as' => 'gallery',
'order' => 3,
]);
Filable::create([
'file_id' => $file->id,
'filable_id' => 1,
'filable_type' => 'App\Models\User',
'as' => 'gallery',
'order' => 1,
]);
Filable::create([
'file_id' => $file->id,
'filable_id' => 1,
'filable_type' => 'App\Models\User',
'as' => 'gallery',
'order' => 2,
]);
$filables = Filable::where('filable_type', 'App\Models\User')->get();
$this->assertEquals(1, $filables[0]->order);
$this->assertEquals(2, $filables[1]->order);
$this->assertEquals(3, $filables[2]->order);
}
// ─── file relationship ─────────────────────────────────────────
public function test_filable_belongs_to_file()
{
$file = File::create([
'name' => 'linked',
'extension' => 'pdf',
'type' => 'application/pdf',
'disk' => 'local',
]);
$filable = Filable::create([
'file_id' => $file->id,
'filable_id' => 1,
'filable_type' => 'App\Models\User',
'as' => 'document',
'order' => 0,
]);
$this->assertEquals($file->id, $filable->file->id);
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace Blax\Files\Tests\Unit;
use Blax\Files\Enums\FileLinkType;
use Blax\Files\FilesServiceProvider;
use Blax\Files\Models\Filable;
use Blax\Files\Models\File;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Orchestra\Testbench\TestCase;
class FileLinkTypeTest extends TestCase
{
// ─── label() ───────────────────────────────────────────────────
public function test_all_cases_have_labels()
{
foreach (FileLinkType::cases() as $case) {
$label = $case->label();
$this->assertNotEmpty($label);
$this->assertIsString($label);
}
}
public function test_specific_labels()
{
$this->assertEquals('Avatar', FileLinkType::Avatar->label());
$this->assertEquals('Profile Image', FileLinkType::ProfileImage->label());
$this->assertEquals('Cover Image', FileLinkType::CoverImage->label());
$this->assertEquals('Other', FileLinkType::Other->label());
}
// ─── isImage() ─────────────────────────────────────────────────
public function test_image_types_return_true()
{
$imageTypes = [
FileLinkType::Avatar,
FileLinkType::ProfileImage,
FileLinkType::CoverImage,
FileLinkType::Banner,
FileLinkType::Background,
FileLinkType::Logo,
FileLinkType::Icon,
FileLinkType::Thumbnail,
FileLinkType::Gallery,
];
foreach ($imageTypes as $type) {
$this->assertTrue($type->isImage(), "{$type->value} should be an image type");
}
}
public function test_non_image_types_return_false()
{
$nonImageTypes = [
FileLinkType::Document,
FileLinkType::Invoice,
FileLinkType::Contract,
FileLinkType::Certificate,
FileLinkType::Report,
FileLinkType::Video,
FileLinkType::Audio,
FileLinkType::Attachment,
FileLinkType::Download,
FileLinkType::Other,
];
foreach ($nonImageTypes as $type) {
$this->assertFalse($type->isImage(), "{$type->value} should NOT be an image type");
}
}
// ─── tryFrom / from ────────────────────────────────────────────
public function test_try_from_valid_value()
{
$this->assertEquals(FileLinkType::Avatar, FileLinkType::tryFrom('avatar'));
$this->assertEquals(FileLinkType::Document, FileLinkType::tryFrom('document'));
}
public function test_try_from_invalid_value_returns_null()
{
$this->assertNull(FileLinkType::tryFrom('nonexistent'));
}
// ─── count ─────────────────────────────────────────────────────
public function test_has_expected_number_of_cases()
{
$this->assertCount(19, FileLinkType::cases());
}
}

View File

@ -0,0 +1,424 @@
<?php
namespace Blax\Files\Tests\Unit;
use Blax\Files\Enums\FileLinkType;
use Blax\Files\FilesServiceProvider;
use Blax\Files\Models\File;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Orchestra\Testbench\TestCase;
class FileModelTest extends TestCase
{
use RefreshDatabase;
protected function getPackageProviders($app): array
{
return [FilesServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
}
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations');
}
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
}
// ─── Creation & UUID ───────────────────────────────────────────
public function test_file_is_created_with_uuid()
{
$file = File::create(['name' => 'test']);
$this->assertNotNull($file->id);
$this->assertIsString($file->id);
$this->assertMatchesRegularExpression(
'/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i',
$file->id,
);
}
public function test_file_sets_default_disk_on_save()
{
$file = File::create(['name' => 'test']);
$this->assertEquals('local', $file->disk);
}
public function test_file_generates_relativepath_on_save()
{
$file = File::create(['name' => 'test']);
$this->assertNotNull($file->relativepath);
$this->assertStringContainsString(now()->format('Y/m/d'), $file->relativepath);
}
public function test_file_preserves_explicit_disk()
{
$file = File::create(['name' => 'test', 'disk' => 's3']);
$this->assertEquals('s3', $file->disk);
}
public function test_file_preserves_explicit_relativepath()
{
$file = File::create([
'name' => 'test',
'relativepath' => 'custom/path/file.txt',
]);
$this->assertEquals('custom/path/file.txt', $file->relativepath);
}
// ─── putContents ───────────────────────────────────────────────
public function test_put_contents_stores_file_on_disk()
{
$file = File::create(['name' => 'hello']);
$file->putContents('Hello, World!');
Storage::disk('local')->assertExists($file->relativepath);
$this->assertEquals('Hello, World!', $file->getContents());
}
public function test_put_contents_detects_extension_when_missing()
{
$file = File::create(['name' => 'test']);
// Plain text content
$file->putContents('plain text content');
$this->assertNotNull($file->extension);
}
public function test_put_contents_detects_mime_type_when_missing()
{
$file = File::create(['name' => 'test']);
$file->putContents('plain text content');
$this->assertNotNull($file->type);
}
public function test_put_contents_calculates_size()
{
$file = File::create(['name' => 'test']);
$content = str_repeat('x', 1234);
$file->putContents($content);
$this->assertEquals(1234, $file->size);
}
public function test_put_contents_does_not_overwrite_existing_extension()
{
$file = File::create(['name' => 'test', 'extension' => 'pdf']);
$file->putContents('fake pdf content');
$this->assertEquals('pdf', $file->extension);
}
// ─── getContents / hasContents ─────────────────────────────────
public function test_get_contents_returns_stored_data()
{
$file = File::create(['name' => 'data']);
$file->putContents('binary data here');
$this->assertEquals('binary data here', $file->getContents());
}
public function test_has_contents_returns_true_when_file_exists()
{
$file = File::create(['name' => 'data']);
$file->putContents('content');
$this->assertTrue($file->hasContents());
}
public function test_has_contents_returns_false_when_file_missing()
{
$file = File::create(['name' => 'ghost']);
$this->assertFalse($file->hasContents());
}
// ─── deleteContents ────────────────────────────────────────────
public function test_delete_contents_removes_file_from_disk()
{
$file = File::create(['name' => 'doomed']);
$file->putContents('to be deleted');
$this->assertTrue($file->hasContents());
$file->deleteContents();
Storage::disk('local')->assertMissing($file->relativepath);
}
// ─── Accessors ─────────────────────────────────────────────────
public function test_size_human_bytes()
{
$file = new File(['size' => 500]);
$this->assertEquals('500 B', $file->size_human);
}
public function test_size_human_kilobytes()
{
$file = new File(['size' => 2048]);
$this->assertEquals('2 KB', $file->size_human);
}
public function test_size_human_megabytes()
{
$file = new File(['size' => 5 * 1048576]);
$this->assertEquals('5 MB', $file->size_human);
}
public function test_size_human_gigabytes()
{
$file = new File(['size' => 2 * 1073741824]);
$this->assertEquals('2 GB', $file->size_human);
}
public function test_size_human_zero()
{
$file = new File(['size' => null]);
$this->assertEquals('0 B', $file->size_human);
}
public function test_url_attribute_uses_warehouse_prefix()
{
$file = File::create(['name' => 'test']);
$this->assertStringContainsString('warehouse/' . $file->id, $file->url);
}
// ─── isImage ───────────────────────────────────────────────────
public function test_is_image_by_type()
{
$file = new File(['type' => 'image/png']);
$this->assertTrue($file->isImage());
}
public function test_is_image_by_extension()
{
foreach (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'] as $ext) {
$file = new File(['extension' => $ext]);
$this->assertTrue($file->isImage(), "Extension '{$ext}' should be detected as image.");
}
}
public function test_is_not_image_for_non_image()
{
$file = new File(['type' => 'application/pdf', 'extension' => 'pdf']);
$this->assertFalse($file->isImage());
}
// ─── meta (JSON) ──────────────────────────────────────────────
public function test_meta_is_cast_to_array()
{
$file = File::create([
'name' => 'test',
'meta' => ['width' => 100, 'height' => 200],
]);
$file->refresh();
$this->assertIsArray($file->meta);
$this->assertEquals(100, $file->meta['width']);
$this->assertEquals(200, $file->meta['height']);
}
// ─── Deletion cascades ─────────────────────────────────────────
public function test_deleting_file_removes_contents_and_filables()
{
$user = \Workbench\App\Models\User::create(['name' => 'Jane', 'email' => 'jane@test.com']);
$file = File::create(['name' => 'bye']);
$file->putContents('farewell');
$user->attachFile($file, 'avatar');
$this->assertDatabaseHas('filables', ['file_id' => $file->id]);
$file->delete();
$this->assertDatabaseMissing('filables', ['file_id' => $file->id]);
Storage::disk('local')->assertMissing($file->relativepath);
}
// ─── putContentsFromUpload ────────────────────────────────────
public function test_put_contents_from_upload_sets_attributes()
{
$file = File::create([]);
$upload = \Illuminate\Http\UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
$file->putContentsFromUpload($upload);
$this->assertEquals('document', $file->name);
$this->assertEquals('pdf', $file->extension);
$this->assertEquals('application/pdf', $file->type);
$this->assertTrue($file->hasContents());
}
// ─── Configurable table name ──────────────────────────────────
public function test_file_uses_configured_table_name()
{
$file = new File;
$this->assertEquals('files', $file->getTable());
}
// ─── Scopes ───────────────────────────────────────────────────
public function test_scope_images_filters_by_type_and_extension()
{
File::create(['name' => 'photo', 'type' => 'image/jpeg', 'extension' => 'jpg']);
File::create(['name' => 'logo', 'type' => null, 'extension' => 'png']);
File::create(['name' => 'doc', 'type' => 'application/pdf', 'extension' => 'pdf']);
$images = File::images()->get();
$this->assertCount(2, $images);
}
public function test_scope_by_extension()
{
File::create(['name' => 'a', 'extension' => 'pdf']);
File::create(['name' => 'b', 'extension' => 'png']);
File::create(['name' => 'c', 'extension' => 'pdf']);
$pdfs = File::byExtension('pdf')->get();
$this->assertCount(2, $pdfs);
}
public function test_scope_by_extension_multiple()
{
File::create(['name' => 'a', 'extension' => 'pdf']);
File::create(['name' => 'b', 'extension' => 'png']);
File::create(['name' => 'c', 'extension' => 'doc']);
$results = File::byExtension('pdf', 'doc')->get();
$this->assertCount(2, $results);
}
public function test_scope_by_disk()
{
File::create(['name' => 'local-file', 'disk' => 'local']);
File::create(['name' => 's3-file', 'disk' => 's3']);
$local = File::byDisk('local')->get();
$this->assertCount(1, $local);
$this->assertEquals('local-file', $local->first()->name);
}
public function test_scope_orphaned()
{
$attached = File::create(['name' => 'attached']);
$orphan = File::create(['name' => 'orphan']);
// Simulate a filable attachment for the first file
\Blax\Files\Models\Filable::create([
'file_id' => $attached->id,
'filable_id' => 1,
'filable_type' => 'App\Models\User',
'as' => 'avatar',
'order' => 0,
]);
$orphans = File::orphaned()->get();
$this->assertCount(1, $orphans);
$this->assertEquals('orphan', $orphans->first()->name);
}
public function test_scope_recent()
{
$recent = File::create(['name' => 'recent']);
$old = File::create(['name' => 'old']);
// Backdate the old file
File::where('id', $old->id)->update(['created_at' => now()->subDays(30)]);
$recentFiles = File::recent(7)->get();
$this->assertCount(1, $recentFiles);
$this->assertEquals('recent', $recentFiles->first()->name);
}
// ─── Download ─────────────────────────────────────────────────
public function test_download_returns_binary_file_response()
{
$file = File::create(['name' => 'doc', 'extension' => 'pdf', 'type' => 'application/pdf']);
$file->putContents('PDF_CONTENT_HERE');
$response = $file->download();
$this->assertInstanceOf(\Symfony\Component\HttpFoundation\BinaryFileResponse::class, $response);
}
public function test_download_with_custom_filename()
{
$file = File::create(['name' => 'doc', 'extension' => 'pdf', 'type' => 'application/pdf']);
$file->putContents('PDF_CONTENT_HERE');
$response = $file->download('custom-name.pdf');
$this->assertInstanceOf(\Symfony\Component\HttpFoundation\BinaryFileResponse::class, $response);
}
// ─── Duplicate ────────────────────────────────────────────────
public function test_duplicate_creates_copy()
{
$original = File::create(['name' => 'original', 'extension' => 'txt', 'type' => 'text/plain']);
$original->putContents('Hello World');
$clone = $original->duplicate();
$this->assertNotEquals($original->id, $clone->id);
$this->assertEquals('original (copy)', $clone->name);
$this->assertEquals('txt', $clone->extension);
$this->assertEquals('Hello World', $clone->getContents());
}
public function test_duplicate_with_custom_name()
{
$original = File::create(['name' => 'original', 'extension' => 'txt', 'type' => 'text/plain']);
$original->putContents('Content');
$clone = $original->duplicate('renamed');
$this->assertEquals('renamed', $clone->name);
}
// ─── toArray ──────────────────────────────────────────────────
public function test_to_array_includes_computed_attributes()
{
$file = File::create(['name' => 'test', 'extension' => 'pdf', 'size' => 2048]);
$array = $file->toArray();
$this->assertArrayHasKey('url', $array);
$this->assertArrayHasKey('size_human', $array);
$this->assertEquals('2 KB', $array['size_human']);
$this->assertStringContainsString('warehouse', $array['url']);
}
}

480
tests/Unit/HasFilesTest.php Normal file
View File

@ -0,0 +1,480 @@
<?php
namespace Blax\Files\Tests\Unit;
use Blax\Files\Enums\FileLinkType;
use Blax\Files\FilesServiceProvider;
use Blax\Files\Models\Filable;
use Blax\Files\Models\File;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Orchestra\Testbench\TestCase;
use Workbench\App\Models\Article;
use Workbench\App\Models\User;
class HasFilesTest extends TestCase
{
use RefreshDatabase;
protected function getPackageProviders($app): array
{
return [FilesServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
}
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations');
}
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
}
// ─── files() relationship ──────────────────────────────────────
public function test_files_returns_morph_to_many()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$this->assertInstanceOf(
\Illuminate\Database\Eloquent\Relations\MorphToMany::class,
$user->files(),
);
}
public function test_files_returns_empty_collection_by_default()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$this->assertCount(0, $user->files);
}
// ─── attachFile ────────────────────────────────────────────────
public function test_attach_file_creates_pivot_entry()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'photo']);
$user->attachFile($file, 'avatar');
$this->assertDatabaseHas('filables', [
'file_id' => $file->id,
'filable_id' => $user->id,
'filable_type' => User::class,
'as' => 'avatar',
]);
}
public function test_attach_file_accepts_file_id_string()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'photo']);
$user->attachFile($file->id, 'avatar');
$this->assertDatabaseHas('filables', [
'file_id' => $file->id,
'filable_id' => $user->id,
]);
}
public function test_attach_file_accepts_enum()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'photo']);
$user->attachFile($file, FileLinkType::Avatar);
$this->assertDatabaseHas('filables', [
'file_id' => $file->id,
'as' => 'avatar',
]);
}
public function test_attach_file_with_order()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'photo']);
$user->attachFile($file, 'gallery', order: 5);
$pivot = $user->getFilePivot($file);
$this->assertEquals(5, $pivot->order);
}
public function test_attach_file_with_meta()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'photo']);
$user->attachFile($file, 'gallery', meta: ['caption' => 'Nice view']);
$pivot = $user->getFilePivot($file);
$decoded = json_decode($pivot->meta, true);
$this->assertEquals('Nice view', $decoded['caption']);
}
public function test_attach_file_prevents_duplicate_pivot()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'photo']);
$user->attachFile($file, 'avatar');
$user->attachFile($file, 'avatar'); // duplicate
$this->assertCount(1, $user->files()->where('file_id', $file->id)->get());
}
public function test_attach_file_allows_same_file_with_different_role()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'photo']);
$user->attachFile($file, 'avatar');
$user->attachFile($file, 'thumbnail');
$this->assertCount(2, $user->files);
}
public function test_attach_file_replace_removes_previous_attachment()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$old = File::create(['name' => 'old']);
$new = File::create(['name' => 'new']);
$user->attachFile($old, FileLinkType::Avatar);
$user->attachFile($new, FileLinkType::Avatar, replace: true);
$user->load('files');
$avatars = $user->filesAs(FileLinkType::Avatar)->get();
$this->assertCount(1, $avatars);
$this->assertEquals($new->id, $avatars->first()->id);
}
// ─── detachFile ────────────────────────────────────────────────
public function test_detach_file_removes_pivot_entry()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'photo']);
$user->attachFile($file, 'avatar');
$user->detachFile($file);
$this->assertDatabaseMissing('filables', [
'file_id' => $file->id,
'filable_id' => $user->id,
]);
}
public function test_detach_file_scoped_by_as()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'multipurpose']);
$user->attachFile($file, 'avatar');
$user->attachFile($file, 'thumbnail');
$user->detachFile($file, 'avatar');
$this->assertDatabaseMissing('filables', ['file_id' => $file->id, 'as' => 'avatar']);
$this->assertDatabaseHas('filables', ['file_id' => $file->id, 'as' => 'thumbnail']);
}
// ─── detachFilesAs ─────────────────────────────────────────────
public function test_detach_files_as_removes_all_files_with_role()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$f1 = File::create(['name' => 'a']);
$f2 = File::create(['name' => 'b']);
$f3 = File::create(['name' => 'c']);
$user->attachFile($f1, 'gallery');
$user->attachFile($f2, 'gallery');
$user->attachFile($f3, 'avatar');
$user->detachFilesAs('gallery');
$this->assertDatabaseMissing('filables', ['as' => 'gallery', 'filable_id' => $user->id]);
$this->assertDatabaseHas('filables', ['as' => 'avatar', 'filable_id' => $user->id]);
}
// ─── detachAllFiles ────────────────────────────────────────────
public function test_detach_all_files()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$f1 = File::create(['name' => 'a']);
$f2 = File::create(['name' => 'b']);
$user->attachFile($f1, 'avatar');
$user->attachFile($f2, 'banner');
$user->detachAllFiles();
$this->assertCount(0, $user->files()->get());
}
// ─── filesAs / fileAs ──────────────────────────────────────────
public function test_files_as_returns_only_matching_role()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$avatar = File::create(['name' => 'avatar']);
$banner = File::create(['name' => 'banner']);
$user->attachFile($avatar, 'avatar');
$user->attachFile($banner, 'banner');
$avatars = $user->filesAs('avatar')->get();
$this->assertCount(1, $avatars);
$this->assertEquals($avatar->id, $avatars->first()->id);
}
public function test_file_as_returns_single_file()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'avatar']);
$user->attachFile($file, FileLinkType::Avatar);
$result = $user->fileAs(FileLinkType::Avatar);
$this->assertNotNull($result);
$this->assertEquals($file->id, $result->id);
}
public function test_file_as_returns_null_when_none_attached()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$this->assertNull($user->fileAs('avatar'));
}
// ─── Convenience getters ───────────────────────────────────────
public function test_get_avatar_returns_avatar_file()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'face']);
$user->attachFile($file, FileLinkType::Avatar);
$this->assertEquals($file->id, $user->getAvatar()->id);
}
public function test_get_avatar_falls_back_to_profile_image()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'face']);
$user->attachFile($file, FileLinkType::ProfileImage);
$this->assertEquals($file->id, $user->getAvatar()->id);
}
public function test_get_thumbnail_returns_thumbnail()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'thumb']);
$user->attachFile($file, FileLinkType::Thumbnail);
$this->assertEquals($file->id, $user->getThumbnail()->id);
}
public function test_get_banner_returns_banner()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'banner-img']);
$user->attachFile($file, FileLinkType::Banner);
$this->assertEquals($file->id, $user->getBanner()->id);
}
public function test_get_cover_image_returns_cover()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'cover']);
$user->attachFile($file, FileLinkType::CoverImage);
$this->assertEquals($file->id, $user->getCoverImage()->id);
}
public function test_get_background_returns_background()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'bg']);
$user->attachFile($file, FileLinkType::Background);
$this->assertEquals($file->id, $user->getBackground()->id);
}
public function test_get_logo_returns_logo()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'logo']);
$user->attachFile($file, FileLinkType::Logo);
$this->assertEquals($file->id, $user->getLogo()->id);
}
public function test_get_gallery_returns_multiple_files()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$f1 = File::create(['name' => 'g1']);
$f2 = File::create(['name' => 'g2']);
$f3 = File::create(['name' => 'g3']);
$user->attachFile($f1, FileLinkType::Gallery, order: 0);
$user->attachFile($f2, FileLinkType::Gallery, order: 1);
$user->attachFile($f3, FileLinkType::Gallery, order: 2);
$gallery = $user->getGallery();
$this->assertCount(3, $gallery);
}
// ─── Polymorphism (multiple models) ────────────────────────────
public function test_same_file_attached_to_different_models()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$article = Article::create(['title' => 'My Post']);
$file = File::create(['name' => 'shared']);
$user->attachFile($file, 'avatar');
$article->attachFile($file, 'thumbnail');
$this->assertCount(1, $user->files);
$this->assertCount(1, $article->files);
$this->assertCount(2, $file->filables);
}
public function test_different_models_files_are_independent()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$article = Article::create(['title' => 'Post']);
$userFile = File::create(['name' => 'user-pic']);
$articleFile = File::create(['name' => 'article-pic']);
$user->attachFile($userFile, 'avatar');
$article->attachFile($articleFile, 'thumbnail');
$user->detachAllFiles();
$this->assertCount(0, $user->files()->get());
$this->assertCount(1, $article->files()->get());
}
// ─── uploadFile ────────────────────────────────────────────────
public function test_upload_file_creates_and_attaches()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$upload = \Illuminate\Http\UploadedFile::fake()->create('doc.pdf', 100, 'application/pdf');
$file = $user->uploadFile($upload, FileLinkType::Document);
$this->assertNotNull($file->id);
$this->assertTrue($file->hasContents());
$this->assertEquals('doc', $file->name);
$this->assertCount(1, $user->files()->get());
$this->assertDatabaseHas('filables', [
'file_id' => $file->id,
'as' => 'document',
]);
}
public function test_upload_file_with_replace()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$u1 = \Illuminate\Http\UploadedFile::fake()->create('old.jpg', 50, 'image/jpeg');
$u2 = \Illuminate\Http\UploadedFile::fake()->create('new.jpg', 50, 'image/jpeg');
$user->uploadFile($u1, FileLinkType::Avatar);
$newFile = $user->uploadFile($u2, FileLinkType::Avatar, replace: true);
$this->assertCount(1, $user->filesAs(FileLinkType::Avatar)->get());
$this->assertEquals($newFile->id, $user->getAvatar()->id);
}
// ─── uploadFileFromContents ────────────────────────────────────
public function test_upload_file_from_contents()
{
$article = Article::create(['title' => 'Post']);
$file = $article->uploadFileFromContents(
'raw content here',
name: 'readme',
extension: 'txt',
as: FileLinkType::Attachment,
);
$this->assertEquals('readme', $file->name);
$this->assertEquals('txt', $file->extension);
$this->assertTrue($file->hasContents());
$this->assertEquals('raw content here', $file->getContents());
}
// ─── getFilePivot ──────────────────────────────────────────────
public function test_get_file_pivot_returns_pivot()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'photo']);
$user->attachFile($file, 'avatar', order: 3);
$pivot = $user->getFilePivot($file);
$this->assertNotNull($pivot);
$this->assertEquals('avatar', $pivot->as);
$this->assertEquals(3, $pivot->order);
}
public function test_get_file_pivot_returns_null_when_not_attached()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$file = File::create(['name' => 'orphan']);
$this->assertNull($user->getFilePivot($file));
}
// ─── reorderFiles ──────────────────────────────────────────────
public function test_reorder_files_sets_order()
{
$user = User::create(['name' => 'Alice', 'email' => 'alice@test.com']);
$f1 = File::create(['name' => 'a']);
$f2 = File::create(['name' => 'b']);
$f3 = File::create(['name' => 'c']);
$user->attachFile($f1, 'gallery');
$user->attachFile($f2, 'gallery');
$user->attachFile($f3, 'gallery');
// Reorder: c, a, b
$user->reorderFiles([$f3->id, $f1->id, $f2->id], 'gallery');
$reloaded = $user->filesAs('gallery')->orderByPivot('order')->get();
$this->assertEquals($f3->id, $reloaded[0]->id);
$this->assertEquals($f1->id, $reloaded[1]->id);
$this->assertEquals($f2->id, $reloaded[2]->id);
}
}

View File

@ -0,0 +1,173 @@
<?php
namespace Blax\Files\Tests\Unit;
use Blax\Files\FilesServiceProvider;
use Blax\Files\Models\File;
use Blax\Files\Services\WarehouseService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Orchestra\Testbench\TestCase;
class WarehouseServiceTest extends TestCase
{
use RefreshDatabase;
protected function getPackageProviders($app): array
{
return [FilesServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
$app['config']->set('app.key', 'base64:' . base64_encode(random_bytes(32)));
}
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations');
}
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
}
// ─── searchFile — UUID lookup ──────────────────────────────────
public function test_search_finds_file_by_uuid()
{
$file = File::create(['name' => 'found']);
$file->putContents('content');
$request = new \Illuminate\Http\Request;
$result = WarehouseService::searchFile($request, $file->id);
$this->assertNotNull($result);
$this->assertEquals($file->id, $result->id);
}
// ─── searchFile — encrypted ID ─────────────────────────────────
public function test_search_finds_file_by_encrypted_id()
{
$file = File::create(['name' => 'encrypted']);
$file->putContents('content');
$encrypted = encrypt($file->id);
$request = new \Illuminate\Http\Request;
$result = WarehouseService::searchFile($request, $encrypted);
$this->assertNotNull($result);
$this->assertEquals($file->id, $result->id);
}
// ─── searchFile — null / empty ─────────────────────────────────
public function test_search_returns_null_for_null_identifier()
{
$request = new \Illuminate\Http\Request;
$result = WarehouseService::searchFile($request, null);
$this->assertNull($result);
}
public function test_search_returns_null_for_nonexistent_id()
{
$request = new \Illuminate\Http\Request;
$result = WarehouseService::searchFile($request, 'nonexistent-uuid-here');
$this->assertNull($result);
}
// ─── searchFile — query string stripping ──────────────────────
public function test_search_strips_query_string_from_identifier()
{
$file = File::create(['name' => 'qs']);
$file->putContents('content');
$request = new \Illuminate\Http\Request;
$result = WarehouseService::searchFile($request, $file->id . '?size=100x100');
$this->assertNotNull($result);
$this->assertEquals($file->id, $result->id);
}
// ─── searchFile — asset path ──────────────────────────────────
public function test_search_finds_asset_by_exact_path()
{
Storage::disk('local')->put('images/logo.png', 'png-data');
$request = new \Illuminate\Http\Request;
$result = WarehouseService::searchFile($request, 'images/logo.png');
$this->assertNotNull($result);
$this->assertEquals('logo.png', $result->name);
$this->assertEquals('png', $result->extension);
}
public function test_search_finds_asset_with_auto_extension()
{
Storage::disk('local')->put('icons/arrow.svg', '<svg/>');
$request = new \Illuminate\Http\Request;
$result = WarehouseService::searchFile($request, 'icons/arrow');
$this->assertNotNull($result);
$this->assertEquals('svg', $result->extension);
}
public function test_search_prefers_svg_over_png_when_both_exist()
{
Storage::disk('local')->put('icons/logo.svg', '<svg/>');
Storage::disk('local')->put('icons/logo.png', 'png-data');
$request = new \Illuminate\Http\Request;
$result = WarehouseService::searchFile($request, 'icons/logo');
$this->assertNotNull($result);
// svg comes first in preferred_extensions
$this->assertEquals('svg', $result->extension);
}
// ─── searchFile — storage path ─────────────────────────────────
public function test_search_finds_by_storage_path()
{
Storage::disk('local')->put('audio/clip.mp3', 'audio-data');
$request = new \Illuminate\Http\Request;
$result = WarehouseService::searchFile($request, 'storage/audio/clip.mp3');
$this->assertNotNull($result);
$this->assertEquals('clip.mp3', $result->name);
}
// ─── url() ─────────────────────────────────────────────────────
public function test_url_generates_warehouse_path()
{
$file = File::create(['name' => 'test']);
$url = WarehouseService::url($file);
$this->assertStringContainsString('warehouse/' . $file->id, $url);
}
public function test_url_accepts_string_id()
{
$url = WarehouseService::url('some-uuid');
$this->assertStringContainsString('warehouse/some-uuid', $url);
}
}