Cookbook

Storage

Configure disks, read/write files, generate URLs, and handle errors

This guide covers the framework's storage layer: configuring disks, reading/writing files, safe path handling, URL generation, and error handling.

Overview

  • Storage core: Glueful\Storage\StorageManager wraps Flysystem and manages multiple disks based on config/storage.php.
  • Path safety: Glueful\Storage\PathGuard validates and normalizes paths (prevents traversal, null bytes, and disallows absolute paths by default).
  • URLs: Glueful\Storage\Support\UrlGenerator formats public URLs using disk base_url/cdn_base_url.
  • Errors: Glueful\Storage\Exceptions\StorageException wraps Flysystem exceptions with machine‑readable reason + suggested HTTP status.
  • DI: Provided via the StorageProvider with a string alias storage.

Configuration

Edit config/storage.php to define your disks.

Basic Configuration

return [
    // Default disk (respects STORAGE_DEFAULT_DISK, or falls back to STORAGE_DRIVER)
    'default' => env('STORAGE_DEFAULT_DISK', env('STORAGE_DRIVER', 'uploads')),

    // PathGuard configuration
    'path_guard' => [
        'allow_absolute' => false,      // Reject absolute paths by default
        'max_path_length' => 4096,      // Maximum path length
        // Traversal ('..') and null bytes ("\0") are always rejected
    ],

    'disks' => [
        // Local filesystem
        'uploads' => [
            'driver' => 'local',
            'root' => dirname(__DIR__) . '/storage/uploads',
            'visibility' => 'private', // 'private' or 'public'
            'base_url' => env('CDN_URL'), // Public URL base
        ],

        // In-memory filesystem (great for testing)
        'memory' => [
            'driver' => 'memory',
        ],
    ],
];

Config files have no $context: use env() and plain paths (dirname(__DIR__)), never config($context, …).

Core drivers: local, memory.

Cloud Disks (glueful/storage-*)

Since framework 1.54 the cloud drivers ship as first-party packs. Install the one you use; a disk whose driver has no pack fails fast with an UnsupportedStorageDriverException naming the package.

composer require glueful/storage-s3 && php glueful extensions:enable storage-s3       # driver: s3 (S3, R2, MinIO, Spaces, Wasabi)
composer require glueful/storage-gcs && php glueful extensions:enable storage-gcs     # driver: gcs
composer require glueful/storage-azure && php glueful extensions:enable storage-azure # driver: azure
// config/storage.php — disks (env names are your choice)
's3' => [
    'driver' => 's3',
    'bucket' => env('AWS_BUCKET'),
    'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
    'key' => env('AWS_ACCESS_KEY_ID'),
    'secret' => env('AWS_SECRET_ACCESS_KEY'),
    'prefix' => env('AWS_PREFIX', ''),
    'cdn_base_url' => env('AWS_CDN_BASE_URL'),
    'signed_ttl' => (int) env('AWS_SIGNED_URL_TTL', 3600),
    'max_signed_ttl' => (int) env('AWS_MAX_SIGNED_URL_TTL', 86400),
],

// S3-compatible services use a preset instead of a separate driver
'r2' => [
    'driver' => 's3',
    'preset' => 'r2', // also: minio, spaces, wasabi
    'account' => env('R2_ACCOUNT_ID'),
    'bucket' => env('R2_BUCKET'),
    'key' => env('R2_ACCESS_KEY_ID'),
    'secret' => env('R2_SECRET_ACCESS_KEY'),
],

'gcs' => [
    'driver' => 'gcs',
    'bucket' => env('GCS_BUCKET'),
    'project_id' => env('GCS_PROJECT_ID'),
    'key_file' => env('GCS_KEY_FILE'), // service-account JSON
    'prefix' => env('GCS_PREFIX', ''),
    'signed_ttl' => (int) env('GCS_SIGNED_URL_TTL', 3600),
],

'azure' => [
    'driver' => 'azure',
    'container' => env('AZURE_STORAGE_CONTAINER'),
    'connection_string' => env('AZURE_STORAGE_CONNECTION_STRING'),
    'prefix' => env('AZURE_STORAGE_PREFIX', ''),
    'signed_ttl' => (int) env('AZURE_SIGNED_URL_TTL', 3600),
],

Each pack's README lists its full options.

Diagnose Disks

php glueful storage:test            # registration, adapter availability, liveness (read-only)
php glueful storage:test s3 --write # one disk, with a write/read/delete smoke test

Public URLs (base_url/cdn_base_url)

Set base_url (or cdn_base_url) on a disk to enable UrlGenerator to build public links:

// config/storage.php
return [
    'default' => env('STORAGE_DEFAULT_DISK', env('STORAGE_DRIVER', 'uploads')),
    'disks' => [
        'uploads' => [
            'driver' => 'local',
            'root' => dirname(__DIR__) . '/storage/uploads',
            'visibility' => 'public',
            'base_url' => env('CDN_URL'), // e.g. https://cdn.example.com
        ],
        's3' => [
            'driver' => 's3', // requires glueful/storage-s3
            'bucket' => env('AWS_BUCKET'),
            'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
            'cdn_base_url' => env('AWS_CDN_BASE_URL'), // takes precedence if set
            'signed_ttl' => (int) env('AWS_SIGNED_URL_TTL', 3600),
        ],
    ],
];

UrlGenerator::url($path, $disk) prefers cdn_base_url if present; otherwise uses base_url. If neither is set, it returns the path unchanged.

Dependency Injection

Resolve services:

use Glueful\Storage\{StorageManager, PathGuard};
use Glueful\Storage\Support\UrlGenerator;

$storage = app($context, StorageManager::class);      // or app($context, 'storage')
$guard   = app($context, PathGuard::class);          // Path validation service
$urls    = app($context, UrlGenerator::class);       // URL generator service

The provider also binds a convenient string alias: app($context, 'storage').

Common Tasks

Choose a disk

$fs = app($context, StorageManager::class)->disk();        // default disk
$s3 = app($context, StorageManager::class)->disk('s3');    // named disk

if (!app($context, StorageManager::class)->diskExists('s3')) {
    // Log or fall back to default
}

Native Flysystem Operations

The disk() method returns a Flysystem FilesystemOperator, giving you access to all native Flysystem methods (v3):

$disk = app($context, StorageManager::class)->disk();

// File operations
if ($disk->fileExists('path/to/file.txt')) {
    $content = $disk->read('path/to/file.txt');
    $disk->write('backup/file.txt', $content);
    $disk->delete('path/to/file.txt');
}

// Copy and move files
$disk->copy('source.txt', 'destination.txt');
$disk->move('old-path.txt', 'new-path.txt');

// Directory operations
$disk->createDirectory('logs/2025');
$disk->deleteDirectory('temp');

// File metadata
$size = $disk->fileSize('document.pdf');           // bytes
$modified = $disk->lastModified('document.pdf');   // timestamp
$mime = $disk->mimeType('image.jpg');              // MIME type

// Visibility (permissions)
$disk->setVisibility('public/file.txt', 'public');
$visibility = $disk->visibility('public/file.txt'); // 'public' or 'private'

// Stream operations
$stream = fopen('large-file.dat', 'r');
$disk->writeStream('uploads/large.dat', $stream);
$readStream = $disk->readStream('uploads/large.dat');

Visibility semantics (Local driver)

  • On the Local driver, permissions are mapped via Flysystem’s PortableVisibilityConverter:
    • Files: public = 0644, private = 0600
    • Directories: public = 0755, private = 0700
  • Default disk visibility is private unless overridden in config/storage.php.
  • For non-local drivers, visibility semantics are adapter-defined.

Write and read JSON

$storage = app($context, StorageManager::class);

$storage->putJson('reports/daily.json', [
    'generated_at' => date(DATE_ATOM),
    'items' => [1, 2, 3],
]);

$data = $storage->getJson('reports/daily.json');

Stream large uploads atomically

$fp = fopen('/path/to/bigfile', 'rb');
app($context, StorageManager::class)->putStream('uploads/big.dat', $fp);

This uses a temporary file strategy: writes to a .tmp file first, then atomically moves it into place. The temp file is cleaned up on failure.

List contents

foreach (app($context, StorageManager::class)->listContents('backups', true) as $entry) {
    // $entry is a StorageAttributes instance
    $path = $entry->path();
    $isFile = $entry->isFile();
    $isDir = $entry->isDir();
}

Generate public URLs

$urlGen = app($context, Glueful\Storage\Support\UrlGenerator::class);

// Generate URL for a file
$url = $urlGen->url('images/logo.png');
// Uses disk base_url/cdn_base_url when configured, else returns the path

// Get disk configuration (useful for signed URLs)
$diskConfig = $urlGen->diskConfig('s3'); // Returns array of disk config

Signed URLs

Presigned links come from the disk's driver: the glueful/storage-* packs implement Glueful\Storage\Contracts\NativeSignedUrlProviderInterface. Use the uploader's Flysystem-backed storage adapter to ask for one:

use Glueful\Uploader\Storage\FlysystemStorage;
use Glueful\Storage\StorageManager;
use Glueful\Storage\Support\UrlGenerator;

$storage = new FlysystemStorage(
    app($context, StorageManager::class),
    app($context, UrlGenerator::class),
    's3'
);

$signedUrl = $storage->getSignedUrl('protected/reports/monthly.pdf', 600); // 10 minutes

Notes:

  • Falls back to the plain URL when the driver cannot sign (or on error).
  • If the expiry is <= 0, uses the disk's signed_ttl.
  • For uploaded blobs, prefer the blob API's app-signed URLs (POST /blobs/{uuid}/signed-url), which keep access control and revocation; uploads.native_urls can add a direct native_url per disk. See File Uploads.

Helper Functions

// Get absolute path to storage directory
$path = storage_path($context);                      // /path/to/project/storage
$path = storage_path($context, 'logs/app.log');      // /path/to/project/storage/logs/app.log

Path Safety

PathGuard enforces security rules on file paths:

Default Configuration

// config/storage.php
'path_guard' => [
    'allow_absolute' => false,              // Reject absolute paths like /etc/passwd
    'max_path_length' => 4096,              // Maximum allowed path length
    // Traversal ('..') and null bytes ("\0") are always rejected
]

Path Validation Rules

  • No null bytes (\0) - prevents string termination attacks
  • No .. traversal - prevents directory traversal attacks
  • Normalized separators - converts backslashes to forward slashes
  • Removes redundant ./ segments
  • Absolute paths rejected unless explicitly allowed
  • Path length limits enforced

Manual Validation

$guard = app($context, Glueful\Storage\PathGuard::class);

try {
    // Validates and normalizes the path
    $safe = $guard->validate('reports/2025/../2025/summary.json');
    // Returns: 'reports/2025/summary.json' (normalized)
} catch (\InvalidArgumentException $e) {
    // Path validation failed
}

Error Handling

Most StorageManager operations convert Flysystem exceptions to StorageException with useful metadata:

Basic Error Handling

use Glueful\Storage\Exceptions\StorageException;

try {
    app($context, StorageManager::class)->getJson('missing/file.json');
} catch (StorageException $e) {
    $reason = $e->reason();      // Machine-readable reason code
    $status = $e->httpStatus();  // Suggested HTTP status code

    // Log with structured data
    $logger = app($context, \Psr\Log\LoggerInterface::class);
    $logger->error('Storage operation failed', [
        'reason' => $reason,
        'status' => $status,
        'message' => $e->getMessage()
    ]);
}

Complete Error Reason Codes

Reason CodeHTTP StatusDescription
io_read_failed404Unable to read file
io_write_failed500Unable to write file
io_delete_failed500Unable to delete file
io_move_failed500Unable to move/rename file
io_copy_failed500Unable to copy file
dir_create_failed500Unable to create directory
dir_delete_failed500Unable to delete directory
existence_check_failed500Unable to check if file exists
metadata_retrieve_failed500Unable to get file metadata
visibility_set_failed403Unable to set permissions
list_failed500Unable to list directory contents
unknown_error500Unclassified error

Parsing Exception Messages

use Glueful\Storage\Support\ExceptionClassifier;

// Parse structured data from exception message
$parsed = ExceptionClassifier::parseFromMessage($e->getMessage());
// Returns: ['reason' => 'io_read_failed', 'http_status' => 404]

Advanced Usage

Custom Disk at Runtime

$disk = app($context, StorageManager::class)->disk('uploads');

// All Flysystem methods available
$disk->write('file.txt', 'content');
$disk->setVisibility('file.txt', 'public');

// Get underlying adapter for advanced operations
if ($disk instanceof \League\Flysystem\Filesystem) {
    $adapter = $disk->getAdapter();
    // Adapter-specific operations
}

Working with Streams

$disk = app($context, StorageManager::class)->disk();

// Read large file as stream
$stream = $disk->readStream('large-file.zip');
while (!feof($stream)) {
    $chunk = fread($stream, 8192);
    // Process chunk
}
fclose($stream);

// Write from stream
$input = fopen('php://input', 'r');
$disk->writeStream('uploads/posted.dat', $input);

Atomic Operations

// StorageManager::putStream() is atomic by default
$fp = fopen('critical-data.json', 'r');
app($context, StorageManager::class)->putStream('config/settings.json', $fp);
// Writes to temp file first, then moves atomically

Testing Tips

Memory Driver for Tests

// config/storage.php (testing environment)
'testing' => [
    'driver' => 'memory',
]
// In your test
$storage = app($context, StorageManager::class)->disk('testing');
$storage->write('test.txt', 'content');
// No actual filesystem writes!

Local Temp Directory

use Glueful\Storage\{StorageManager, PathGuard};

// Create an isolated disk for the test
$tempDir = sys_get_temp_dir() . '/test-' . uniqid();
mkdir($tempDir);

$sm = new StorageManager([
    'default' => 'test',
    'disks' => [
        'test' => [
            'driver' => 'local',
            'root' => $tempDir,
            'visibility' => 'private',
        ],
    ],
], new PathGuard());

$disk = $sm->disk('test');
$disk->write('example.txt', 'ok');

// Cleanup: delete all entries under the temp root
foreach ($disk->listContents('', true) as $entry) {
    if ($entry->isFile()) {
        $disk->delete($entry->path());
    } elseif ($entry->isDir()) {
        $disk->deleteDirectory($entry->path());
    }
}
rmdir($tempDir);

URL Testing

config() reads configuration; it is not a setter. Build a UrlGenerator with the config you want to test:

use Glueful\Storage\PathGuard;
use Glueful\Storage\Support\UrlGenerator;

$urls = new UrlGenerator([
    'default' => 'uploads',
    'disks' => ['uploads' => ['driver' => 'local', 'base_url' => 'https://cdn.test']],
], new PathGuard());

$this->assertEquals('https://cdn.test/image.jpg', $urls->url('image.jpg', 'uploads'));

Reference

Core Classes

  • Glueful\Storage\StorageManager
    • disk(?string $name = null): FilesystemOperator - Get disk instance
    • diskExists(string $name): bool - Check if disk configured
    • putJson(string $path, mixed $data, ?string $disk = null): void
    • getJson(string $path, ?string $disk = null): mixed
    • put(string $path, string $contents, ?string $disk = null): void
    • fileExists(string $path, ?string $disk = null): bool
    • delete(string $path, ?string $disk = null): void
    • putStream(string $path, $stream, ?string $disk = null): void - Atomic write
    • listContents(string $path, bool $recursive = false, ?string $disk = null): iterable
  • Glueful\Storage\PathGuard
    • validate(string $path): string - Validate and normalize path
  • Glueful\Storage\Support\UrlGenerator
    • url(string $path, ?string $disk = null): string - Generate public URL
    • diskConfig(string $disk): array - Get disk configuration
  • Glueful\Storage\Exceptions\StorageException
    • reason(): ?string - Get error reason code
    • httpStatus(): ?int - Get suggested HTTP status
    • fromFlysystem(FilesystemException $e, string $path = ''): self

Helper Functions

  • storage_path($context, string $path = ''): string - Get storage directory path