Search by

siberfx / laravel-rustfs

siberfx

RustFS for Laravel 13+: S3 data plane (buckets, objects, versioning, object lock, lifecycle, replication, encryption, notifications, select), the full RustFS Admin API (IAM, quotas, tiers, pools, heal, KMS, site replication...) and a "rustfs" filesystem disk.

1.0.1 2026-10-22 22:23 UTC

This package is auto-updated.

Last update: 2026-10-22 22:23:23 UTC


README

tests Latest Version PHP Laravel License

Use RustFS, the high-performance S3-compatible object store written in Rust, from Laravel 13+. The package gives you:

  • a rustfs filesystem disk for Storage::disk(...),
  • the S3 feature set RustFS supports: versioning, object lock (WORM), lifecycle, replication, encryption, policies, tags, CORS, event notifications, multipart uploads, presigned URLs and forms, and S3 Select,
  • the RustFS Admin API: IAM, service accounts, quotas, tiers, pools, healing, KMS, site replication, config, notification and audit targets, diagnostics, tracing,
  • Artisan commands for health checks, server info, buckets and users.

Every sample in this README uses the real API, and the behaviour it describes is covered by the live test suite running against a real RustFS server.

Contents

Installation

Requirements: PHP 8.4.27+, Laravel 13, RustFS 1.0.x.

composer require siberfx/laravel-rustfs
php artisan vendor:publish --tag=rustfs-config   # optional: config/rustfs.php

The service provider and the RustFs facade are auto-discovered.

Need a RustFS server?

# Laravel Herd Pro: enable the RustFS service. It runs at localhost:9000 with herd / secretkey

# Docker
docker run -d --name rustfs -p 9000:9000 -p 9001:9001 \
  -e RUSTFS_ACCESS_KEY=rustfsadmin -e RUSTFS_SECRET_KEY=change-me \
  -v rustfs-data:/data rustfs/rustfs:1.0.3

Port 9000 is the S3/Admin API. Port 9001 is the web console.

Configuration

RUSTFS_ENDPOINT=http://127.0.0.1:9000
RUSTFS_ACCESS_KEY=rustfsadmin
RUSTFS_SECRET_KEY=change-me
RUSTFS_REGION=us-east-1
RUSTFS_BUCKET=media

Verify the connection:

php artisan rustfs:health
Variable Default Purpose
RUSTFS_ENDPOINT http://127.0.0.1:9000 API URL (not the console port)
RUSTFS_ACCESS_KEY / RUSTFS_SECRET_KEY rustfsadmin Credentials. Admin calls need admin rights
RUSTFS_SESSION_TOKEN – For temporary STS credentials
RUSTFS_REGION us-east-1 Signing region. Must match the server
RUSTFS_BUCKET – Default bucket for RustFs::objects() and disks
RUSTFS_PATH_STYLE true host/bucket/key addressing
RUSTFS_ADMIN_PREFIX /rustfs/admin Plain-JSON admin API prefix
RUSTFS_ADMIN_RETRIES / RUSTFS_ADMIN_RETRY_DELAY_MS 3 / 1000 Retries for admin 503 responses
RUSTFS_TIMEOUT / RUSTFS_CONNECT_TIMEOUT / RUSTFS_VERIFY_TLS 30 / 5 / true HTTP options

Every option is explained in docs/01-installation-and-configuration.md.

Files with the rustfs disk

Add disks to config/filesystems.php. Anything a disk leaves out is taken from its RustFS connection:

'disks' => [
    'rustfs'  => ['driver' => 'rustfs'],                                  // uses RUSTFS_BUCKET
    'media'   => ['driver' => 'rustfs', 'bucket' => 'media'],
    'backups' => ['driver' => 'rustfs', 'bucket' => 'backups', 'root' => 'nightly', 'throw' => true],
    'assets'  => ['driver' => 'rustfs', 'bucket' => 'assets', 'url' => 'https://cdn.example.com'],
],

Optionally make it the default in .env:

FILESYSTEM_DISK=rustfs

The full Laravel filesystem API works:

use Illuminate\Support\Facades\Storage;

$disk = Storage::disk('media');

$disk->put('notes/hello.txt', 'Hello RustFS');
$disk->writeStream('videos/intro.mp4', fopen('/path/intro.mp4', 'rb'));   // streamed, low memory

$disk->exists('notes/hello.txt');          // true
$disk->get('notes/hello.txt');             // "Hello RustFS"
$disk->size('notes/hello.txt');            // 12
$disk->lastModified('notes/hello.txt');    // unix timestamp
$disk->mimeType('notes/hello.txt');        // "text/plain"

$disk->files('notes');                     // ['notes/hello.txt']
$disk->allFiles();                         // recursive
$disk->copy('notes/hello.txt', 'notes/copy.txt');
$disk->move('notes/copy.txt', 'archive/copy.txt');
$disk->delete(['notes/hello.txt', 'archive/copy.txt']);
$disk->deleteDirectory('tmp');

$disk->temporaryUrl('notes/hello.txt', now()->addMinutes(10));   // signed download link

The disk also exposes RustFS extras:

$disk = Storage::disk('backups');                 // root = "nightly"

$disk->setTags('db.sql.gz', ['retention' => '30d']);
$disk->getTags('db.sql.gz');                      // ['retention' => '30d']

foreach ($disk->versions('db.sql.gz') as $version) {   // needs versioning on the bucket
    echo $version['VersionId'], PHP_EOL;
}

$disk->key('db.sql.gz');                          // "nightly/db.sql.gz", the real object key
$disk->objects();                                 // full object API for the disk's bucket
$disk->buckets();                                 // bucket API
$disk->getClient();                               // Aws\S3\S3Client

objects() takes bucket keys, so wrap paths with $disk->key($path) when the disk has a root.

Recipes

All recipes use the RustFs facade:

use Siberfx\RustFs\Facades\RustFs;

$buckets = RustFs::buckets();              // bucket-level API
$objects = RustFs::objects('media');       // object API for one bucket (omit the name to use RUSTFS_BUCKET)
$admin   = RustFs::admin();                // Admin API

Upload a file from a form

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class AvatarController
{
    public function store(Request $request)
    {
        $request->validate(['avatar' => ['required', 'image', 'max:5120']]);

        $path = $request->file('avatar')->store("avatars/{$request->user()->id}", 'media');

        $request->user()->update(['avatar_path' => $path]);

        return back();
    }
}

Need metadata, a content type or encryption? Use the object API:

RustFs::objects('media')->put("avatars/{$user->id}.png", $png, [
    'ContentType' => 'image/png',
    'CacheControl' => 'max-age=86400',
    'Metadata' => ['user-id' => (string) $user->id],
]);

Private downloads with expiring links

Files stay private. Users get a signed link that expires:

public function download(Invoice $invoice)
{
    $this->authorize('view', $invoice);

    return redirect(Storage::disk('media')->temporaryUrl(
        $invoice->path,
        now()->addMinutes(5),
        ['ResponseContentDisposition' => 'attachment; filename="invoice-' . $invoice->number . '.pdf"'],
    ));
}

Or stream through your app without loading the file into memory:

return response()->stream(function () use ($invoice) {
    $stream = RustFs::objects('media')->stream($invoice->path);
    while (! $stream->eof()) {
        echo $stream->read(1024 * 64);
    }
}, 200, ['Content-Type' => 'application/pdf']);

Direct browser uploads (presigned PUT)

The browser uploads straight to RustFS, so your server never touches the bytes:

// routes/api.php
Route::post('/uploads/sign', function (Request $request) {
    $request->validate(['name' => 'required|string', 'type' => 'required|string']);

    $key = 'incoming/' . Str::uuid() . '/' . basename($request->string('name'));

    ['url' => $url] = Storage::disk('media')->temporaryUploadUrl(
        $key,
        now()->addMinutes(10),
        ['ContentType' => $request->string('type')->toString()],
    );

    return ['key' => $key, 'url' => $url];
});
const { url, key } = await (await fetch('/api/uploads/sign', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
    body: JSON.stringify({ name: file.name, type: file.type }),
})).json();

await fetch(url, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file });
// then tell your API the upload finished, sending `key`

The browser's Content-Type header is what gets stored as the object's type, so always send it.

A presigned PUT does not enforce type or size: on RustFS 1.0.x, an upload with a different Content-Type than the one you signed is still accepted. If you need the server to enforce limits, use a presigned POST, or validate the object (head()) before you accept the key. The bucket also needs a CORS rule that allows PUT from your origin (see CORS).

HTML form uploads with server-side limits (presigned POST)

RustFS itself enforces the key prefix, size and type:

$form = RustFs::objects('media')->presignedPost(
    formInputs: ['key' => 'uploads/${filename}', 'Content-Type' => 'image/jpeg'],
    conditions: [
        ['starts-with', '$key', 'uploads/'],
        ['content-length-range', 1, 10 * 1024 * 1024],   // 1 byte to 10 MiB
        ['eq', '$Content-Type', 'image/jpeg'],
    ],
    expires: '+30 minutes',
);

return view('upload', ['form' => $form]);
<form action="{{ $form['url'] }}" method="POST" enctype="multipart/form-data">
    @foreach ($form['fields'] as $name => $value)
        <input type="hidden" name="{{ $name }}" value="{{ $value }}">
    @endforeach
    <input type="file" name="file">   {{-- must be the last field --}}
    <button>Upload</button>
</form>

Large and resumable uploads (multipart)

Server side. upload() switches to a parallel multipart upload above the threshold:

RustFs::objects('backups')->upload(
    'db/2026-10-23.sql.gz',
    storage_path('app/dump.sql.gz'),          // a path, resource, stream or string
    params: ['ContentType' => 'application/gzip'],
    multipartThreshold: 64 * 1024 * 1024,
    partSize: 32 * 1024 * 1024,
    concurrency: 8,
);

Client side (resumable, chunked). The browser uploads each part to its own presigned URL:

$objects = RustFs::objects('media');

// 1. start
$uploadId = $objects->createMultipartUpload('videos/raw.mov', ['ContentType' => 'video/quicktime']);

// 2. one presigned URL per part. The client PUTs each chunk (at least 5 MiB, except the last)
$urls = collect(range(1, $partCount))->mapWithKeys(fn (int $n) => [
    $n => $objects->temporaryPartUploadUrl('videos/raw.mov', $uploadId, $n, '+2 hours'),
]);

// 3. the client returns the ETag response header of each part
$objects->completeMultipartUpload('videos/raw.mov', $uploadId, [
    ['PartNumber' => 1, 'ETag' => '"etag-1"'],
    ['PartNumber' => 2, 'ETag' => '"etag-2"'],
]);

// To resume: list what has arrived. To give up: abort
$objects->parts('videos/raw.mov', $uploadId);
$objects->abortMultipartUpload('videos/raw.mov', $uploadId);
$objects->multipartUploads('videos/');      // unfinished uploads

Serve a public folder

RustFS ignores object ACLs, so 'visibility' => 'public' does not make files public. Grant anonymous read with a bucket policy:

RustFs::buckets()->makePublic('media', 'public/');   // everything under public/

Storage::disk('media')->put('public/logo.svg', $svg);
Storage::disk('media')->url('public/logo.svg');       // http://127.0.0.1:9000/media/public/logo.svg

Custom policy:

RustFs::buckets()->putPolicy('media', [
    'Version' => '2012-10-17',
    'Statement' => [[
        'Effect' => 'Allow',
        'Principal' => ['AWS' => ['*']],
        'Action' => ['s3:GetObject'],
        'Resource' => ['arn:aws:s3:::media/public/*', 'arn:aws:s3:::media/shared/*'],
    ]],
]);

RustFs::buckets()->policy('media');        // the current policy (or null)
RustFs::buckets()->deletePolicy('media');  // private again

Keep every version of a document

$buckets = RustFs::buckets();
$buckets->create('contracts', versioning: true);    // or enableVersioning('contracts') later

$objects = RustFs::objects('contracts');
$v1 = $objects->put('acme.pdf', $draft)['VersionId'];
$v2 = $objects->put('acme.pdf', $final)['VersionId'];

$objects->get('acme.pdf');                   // latest ($final)
$objects->get('acme.pdf', versionId: $v1);   // the draft

foreach ($objects->versions('acme.pdf') as $version) {
    // ['Key', 'VersionId', 'IsLatest', 'LastModified', 'Size', 'IsDeleteMarker' => false]
}

// Restore the draft as the current version
$objects->copy('acme.pdf', 'acme.pdf', sourceVersionId: $v1);

// "Delete" adds a delete marker. Remove a version for good with its id
$objects->delete('acme.pdf');
$objects->delete('acme.pdf', versionId: $v2);

Immutable records with object lock (WORM)

Object lock can only be enabled when the bucket is created:

$buckets = RustFs::buckets();
$buckets->create('audit-records', objectLock: true);

// Default: every new object is locked for 7 years
$buckets->putObjectLock('audit-records', 'COMPLIANCE', years: 7);

$objects = RustFs::objects('audit-records');
$version = $objects->put('2026/10/ledger.csv', $csv)['VersionId'];

$objects->retention('2026/10/ledger.csv', $version);
// ['Mode' => 'COMPLIANCE', 'RetainUntilDate' => DateTime]

// Per-object retention (GOVERNANCE can be bypassed by privileged users, COMPLIANCE cannot)
$objects->putRetention('drafts/a.pdf', 'GOVERNANCE', now()->addDays(30)->toDateTimeImmutable(), $version);
$objects->delete('drafts/a.pdf', $version, bypassGovernanceRetention: true);

// Legal hold: no expiry, stays until it is removed
$objects->putLegalHold('evidence/case-42.zip', true);
$objects->legalHold('evidence/case-42.zip');            // true
$objects->putLegalHold('evidence/case-42.zip', false);

Expire temp files and archive old data (lifecycle)

RustFs::buckets()->putLifecycle('media', [
    [
        'ID' => 'expire-tmp',
        'Status' => 'Enabled',
        'Filter' => ['Prefix' => 'tmp/'],
        'Expiration' => ['Days' => 1],
    ],
    [
        'ID' => 'drop-old-versions',
        'Status' => 'Enabled',
        'Filter' => ['Prefix' => ''],
        'NoncurrentVersionExpiration' => ['NoncurrentDays' => 30],
    ],
    [
        'ID' => 'abort-stale-uploads',
        'Status' => 'Enabled',
        'Filter' => ['Prefix' => ''],
        'AbortIncompleteMultipartUpload' => ['DaysAfterInitiation' => 2],
    ],
    [
        'ID' => 'archive-after-90-days',      // needs a tier, see below
        'Status' => 'Enabled',
        'Filter' => ['Prefix' => 'archive/'],
        'Transitions' => [['Days' => 90, 'StorageClass' => 'COLD']],
    ],
]);

RustFs::buckets()->lifecycle('media');        // the current rules
RustFs::buckets()->deleteLifecycle('media');

Register the COLD tier (any S3-compatible target) once:

RustFs::admin()->tiers()->add([
    'Version' => 'v1',
    'Type' => 's3',
    'Name' => 'COLD',
    'S3' => [
        'Endpoint' => 'https://cold-storage.example.com',
        'AccessKey' => env('COLD_KEY'),
        'SecretKey' => env('COLD_SECRET'),
        'Bucket' => 'rustfs-cold',
        'Region' => 'us-east-1',
    ],
]);

RustFs::objects('media')->restore('archive/2020.tar', days: 3);   // bring one back temporarily

Encrypt everything at rest (KMS)

$admin = RustFs::admin();

$admin->kms()->serviceStatus();        // backend_type, healthy, ...
$key = $admin->kms()->createKey('Billing data', ['team' => 'finance']);

// Default bucket encryption: every new object is encrypted with this key
RustFs::buckets()->putEncryption('billing', 'aws:kms', $key['key_id']);
// ...or SSE-S3 with server-managed keys
RustFs::buckets()->putEncryption('media', 'AES256');

// Per object
RustFs::objects('billing')->put('inv-1.pdf', $pdf, [
    'ServerSideEncryption' => 'aws:kms',
    'SSEKMSKeyId' => $key['key_id'],
]);

$admin->kms()->rotateKey($key['key_id']);
$admin->kms()->listKeys();

KMS must be configured on the server, for example rustfs server --kms-enable --kms-backend local ... (or Vault/AWS KMS in production).

React to uploads (event notifications)

$admin = RustFs::admin();

// 1. Enable the notify module (once per deployment)
$admin->config()->updateModuleSwitches(notify: true);

// 2. Create a webhook target
$admin->notifications()->set('webhook', 'app', [
    'endpoint' => 'https://app.example.com/rustfs/events',
    'auth_token' => 'Bearer ' . config('services.rustfs.webhook_token'),
    'queue_dir' => '/var/lib/rustfs/events',   // absolute path that exists on the RustFS host
    'enable' => true,
]);

// 3. Subscribe a bucket
$arn = $admin->notifications()->arns()[0];      // "arn:rustfs:sqs:us-east-1:app:webhook"
RustFs::buckets()->addNotification('media', $arn, ['s3:ObjectCreated:*'], prefix: 'uploads/', suffix: '.jpg');

Receive the events in Laravel:

// routes/api.php
Route::post('/rustfs/events', function (Request $request) {
    abort_unless($request->bearerToken() === config('services.rustfs.webhook_token'), 403);

    foreach ($request->input('Records', []) as $record) {
        ProcessUpload::dispatch(
            $record['s3']['bucket']['name'],
            urldecode($record['s3']['object']['key']),
        );
    }

    return response()->noContent();
});

Other target types: kafka, amqp, mqtt, nats, nsq, redis, mysql, postgres, pulsar, elasticsearch. RustFS blocks loopback endpoints unless they are allowed with RUSTFS_OUTBOUND_ALLOW_ORIGINS. More details are in docs/03-buckets.md.

Query CSV/JSON without downloading it (S3 Select)

$objects = RustFs::objects('reports');

// CSV with a header row
$rows = $objects->selectRows('sales-2026.csv', "SELECT s.country, s.total FROM S3Object s WHERE s.country = 'TR'");
// [['country' => 'TR', 'total' => '1200'], ...]

// JSON lines, gzip compressed
$errors = $objects->selectRows(
    'logs/2026-10-23.json.gz',
    "SELECT e.message FROM S3Object[*] e WHERE e.level = 'error'",
    input: ['JSON' => ['Type' => 'LINES'], 'CompressionType' => 'GZIP'],
);

// Raw output, here CSV
$csv = $objects->select('sales-2026.csv', 'SELECT * FROM S3Object s LIMIT 100', output: ['CSV' => []]);

Tag objects and buckets

RustFs::objects('media')->putTags('docs/plan.pdf', ['classification' => 'internal', 'owner' => 'alice']);
RustFs::objects('media')->tags('docs/plan.pdf');         // ['classification' => 'internal', 'owner' => 'alice']
RustFs::objects('media')->deleteTags('docs/plan.pdf');

RustFs::buckets()->putTags('media', ['cost-center' => '42']);
RustFs::buckets()->tags('media');

Tags can drive lifecycle filters ('Filter' => ['Tag' => ['Key' => 'temp', 'Value' => 'true']]).

Copy, move and bulk-delete

$objects = RustFs::objects('media');

$objects->copy('a.jpg', 'b.jpg');                                     // same bucket
$objects->copy('incoming/a.jpg', 'photos/a.jpg', sourceBucket: 'inbox');   // across buckets
$objects->copy('a.jpg', 'a.jpg', options: ['MetadataDirective' => 'REPLACE', 'ContentType' => 'image/jpeg']);

// Lazily list millions of keys
foreach ($objects->list('photos/2026/') as $object) {
    // ['Key' => ..., 'Size' => ..., 'LastModified' => ..., 'ETag' => ...]
}
foreach ($objects->list('photos/', recursive: false) as $entry) {
    // sub-folders come back as ['Prefix' => 'photos/2026/', 'IsDirectory' => true]
}

$errors = $objects->deleteMany(['a.jpg', 'b.jpg', ['Key' => 'c.jpg', 'VersionId' => 'v1']]);   // batches of 1000
$count = $objects->deletePrefix('tmp/');

RustFs::buckets()->empty('scratch');                  // every version and delete marker
RustFs::buckets()->delete('scratch', force: true);    // empty, then delete

Allow your SPA to talk to RustFS (CORS)

RustFs::buckets()->putCors('media', [[
    'AllowedOrigins' => ['https://app.example.com'],
    'AllowedMethods' => ['GET', 'PUT', 'POST'],
    'AllowedHeaders' => ['*'],
    'ExposeHeaders' => ['ETag'],
    'MaxAgeSeconds' => 3600,
]]);

Replicate a bucket to a DR site

$admin = RustFs::admin();

// Both buckets need versioning
RustFs::buckets()->enableVersioning('photos');

$arn = $admin->replication()->setRemoteTarget('photos', [
    'endpoint' => 'dr.example.com:9000',
    'secure' => true,
    'targetbucket' => 'photos',
    'credentials' => ['accessKey' => env('DR_KEY'), 'secretKey' => env('DR_SECRET')],
    'type' => 'replication',
]);

RustFs::buckets()->putReplication('photos', [
    'Rules' => [[
        'ID' => 'to-dr',
        'Status' => 'Enabled',
        'Priority' => 1,
        'Filter' => ['Prefix' => ''],
        'DeleteMarkerReplication' => ['Status' => 'Enabled'],
        'Destination' => ['Bucket' => $arn],
    ]],
]);

$admin->replication()->metrics('photos');   // replicated / pending / failed

To mirror everything (all buckets, IAM, policies) between deployments, use $admin->siteReplication()->add([...]). See docs/07.

Administration

Admin calls use the connection's credentials, which must have admin rights. Check with RustFs::admin()->account()->isAdmin().

Provision a tenant: bucket, user, policy, quota

namespace App\Actions;

use Illuminate\Support\Str;
use Siberfx\RustFs\Facades\RustFs;

class ProvisionTenantStorage
{
    /** @return array{bucket: string, accessKey: string, secretKey: string} */
    public function __invoke(string $tenant, int $quotaGb = 10): array
    {
        $bucket = "tenant-{$tenant}";
        $admin = RustFs::admin();

        RustFs::buckets()->create($bucket, versioning: true);

        $admin->policies()->create("{$bucket}-rw", [
            'Version' => '2012-10-17',
            'Statement' => [[
                'Effect' => 'Allow',
                'Action' => ['s3:*'],
                'Resource' => ["arn:aws:s3:::{$bucket}", "arn:aws:s3:::{$bucket}/*"],
            ]],
        ]);

        $secret = Str::password(40, symbols: false);
        $admin->users()->create($bucket, $secret, policy: "{$bucket}-rw");

        $admin->quotas()->set($bucket, $quotaGb * 1024 ** 3);

        return ['bucket' => $bucket, 'accessKey' => $bucket, 'secretKey' => $secret];
    }
}

Managing users afterwards:

$users = RustFs::admin()->users();

$users->list();                          // ['tenant-acme' => ['status' => 'enabled', 'policyName' => 'tenant-acme-rw', ...]]
$users->info('tenant-acme');
$users->disable('tenant-acme');          // suspend access
$users->enable('tenant-acme');
$users->setSecretKey('tenant-acme', Str::password(40, symbols: false));   // rotate the secret
$users->setPolicy('tenant-acme', 'readonly');
$users->delete('tenant-acme');

RustFs::admin()->quotas()->stats('tenant-acme');   // quota_limit, current_usage, remaining_quota, usage_percentage
RustFs::admin()->quotas()->check('tenant-acme', 500 * 1024 ** 2);   // ['allowed' => true, ...]

Service accounts for apps and CI

Service accounts are extra keys that belong to a user. They can be narrowed with a policy and set to expire:

$credentials = RustFs::admin()->serviceAccounts()->create(
    targetUser: 'tenant-acme',
    name: 'github-actions',
    description: 'Deploys release assets',
    expiration: now()->addMonths(3),
    policy: [
        'Version' => '2012-10-17',
        'Statement' => [['Effect' => 'Allow', 'Action' => ['s3:PutObject'], 'Resource' => ['arn:aws:s3:::tenant-acme/releases/*']]],
    ],
);
// ['accessKey' => '...', 'secretKey' => '...']. The secret is shown only once

$accounts = RustFs::admin()->serviceAccounts();
$accounts->list('tenant-acme');
$accounts->disable($credentials['accessKey']);
$accounts->update($credentials['accessKey'], newExpiration: now()->addYear());
$accounts->delete($credentials['accessKey']);

Groups

$groups = RustFs::admin()->groups();

$groups->create('editors', ['alice', 'bob']);
$groups->setPolicy('editors', 'readwrite');
$groups->addMembers('editors', ['carol']);
$groups->info('editors');                    // ['name', 'status', 'members', 'policy', ...]
$groups->removeMembers('editors', ['alice', 'bob', 'carol']);
$groups->delete('editors');                  // only empty groups can be deleted

// Add or remove policies without replacing the existing ones
RustFs::admin()->policies()->attach(['diagnostics'], group: 'editors');
RustFs::admin()->policies()->detach(['diagnostics'], group: 'editors');

Cluster status, usage and healing

$admin = RustFs::admin();

$info = $admin->system()->info();
$info['mode'];                         // "online"
$info['servers'][0]['version'];
$info['buckets']['count'];

$admin->system()->storageInfo();       // drives, capacity, state
$admin->system()->dataUsageInfo();     // objects, sizes, per-bucket usage (from the scanner)
$admin->pools()->list();

$admin->heal()->start('media', 'photos/', ['recursive' => true]);   // repair a prefix
$admin->heal()->backgroundStatus();

$admin->diagnostics()->scannerStatus();
$admin->diagnostics()->topLocks();

Server configuration

$config = RustFs::admin()->config();

$config->help();                                   // sub-systems: storage_class, scanner, heal, notify_*, audit_*, ...
$config->get('scanner');                           // 'scanner cycle_max_duration="1800" idle_mode="true" ...'
$applied = $config->set('scanner idle_mode=false');   // true = applied without a restart
$config->history();
$config->moduleSwitches();                         // notify / audit modules

Backups of IAM and bucket metadata

// app/Console/Commands/BackupRustFsMetadata.php
public function handle(): void
{
    $admin = RustFs::admin();
    $stamp = now()->format('Y-m-d');

    Storage::disk('backups')->put("rustfs/iam-{$stamp}.zip", $admin->iam()->export());
    Storage::disk('backups')->put("rustfs/buckets-{$stamp}.zip", $admin->buckets()->exportMetadata());
}

// Restore
$admin->iam()->import(Storage::disk('backups')->get('rustfs/iam-2026-10-23.zip'));
$admin->buckets()->importMetadata(Storage::disk('backups')->get('rustfs/buckets-2026-10-23.zip'));

Live request tracing

foreach (RustFs::admin()->profiling()->traceEvents(['s3' => true, 'err' => true], limit: 50) as $event) {
    logger()->info('rustfs', [$event['funcname'] ?? null, $event['path'] ?? null, $event['duration'] ?? null]);
}

Anything else: raw admin requests

Every RustFS admin route is reachable, including ones without a wrapper:

RustFs::admin()->request('GET', '/v4/heal/replacement-recovery');
RustFs::admin()->request('POST', '/v3/speedtest/object', ['size' => 64 * 1024 * 1024, 'duration' => '10s']);
$response = RustFs::admin()->send('GET', '/v3/profiling/download');   // raw PSR-7 response for binary data

The admin route map lists every wrapped method and its endpoint.

Health checks

RustFs::health()->live();          // the process is serving
RustFs::health()->ready();         // ready for traffic
RustFs::health()->cluster();       // write quorum
RustFs::health()->clusterRead();   // read quorum

Use them in Laravel's health route or a scheduler:

// routes/console.php
Schedule::command('rustfs:health')->everyFiveMinutes()->emailOutputOnFailure('ops@example.com');

Artisan commands

php artisan rustfs:health                                 # exits non-zero if a probe fails
php artisan rustfs:info [--json]                          # mode, deployment, usage, servers

php artisan rustfs:bucket list
php artisan rustfs:bucket create invoices --lock          # object lock (WORM)
php artisan rustfs:bucket create docs --versioned
php artisan rustfs:bucket info media
php artisan rustfs:bucket versioning media on|off
php artisan rustfs:bucket quota media 10737418240|clear
php artisan rustfs:bucket delete scratch --force

php artisan rustfs:user list
php artisan rustfs:user create alice --policy=readwrite   # prints a generated secret
php artisan rustfs:user disable|enable|info|delete alice
php artisan rustfs:user policy alice --policy=readonly

All commands accept --connection=<name>.

Multiple RustFS deployments

// config/rustfs.php
'connections' => [
    'default' => [ /* ... */ ],
    'backup' => [
        'endpoint' => env('RUSTFS_BACKUP_ENDPOINT'),
        'key' => env('RUSTFS_BACKUP_KEY'),
        'secret' => env('RUSTFS_BACKUP_SECRET'),
        'region' => 'eu-central-1',
        'bucket' => 'archive',
    ],
],
RustFs::connection('backup')->objects()->upload('db.sql.gz', $path);
RustFs::connection('backup')->admin()->system()->info();

// Disks pick a connection with "connection"
'offsite' => ['driver' => 'rustfs', 'connection' => 'backup', 'root' => 'nightly'],

Dependency injection works too:

use Siberfx\RustFs\RustFsManager;

public function __construct(private RustFsManager $rustfs) {}

$this->rustfs->connection('backup')->buckets()->list();

Error handling

use Aws\S3\Exception\S3Exception;
use Siberfx\RustFs\Exceptions\AdminApiException;
use Siberfx\RustFs\Exceptions\RustFsException;

try {
    RustFs::admin()->groups()->delete('editors');
} catch (AdminApiException $e) {
    $e->status;       // 400
    $e->errorCode;    // "InvalidRequest"
    $e->getMessage(); // "RustFS admin DELETE /rustfs/admin/v3/group/editors failed with 400 [InvalidRequest]: group is not empty"
    $e->requestId;    // for the server logs
}

try {
    RustFs::objects('media')->get('missing.txt');
} catch (S3Exception $e) {
    $e->getAwsErrorCode();   // "NoSuchKey"
}
  • AdminApiException extends RustFsException. RustFsException also covers configuration errors, such as an unknown connection or a missing bucket.
  • Reads of settings that were never configured return null / [] instead of throwing (policy(), lifecycle(), encryption(), tags(), ...).
  • head() returns null for missing objects, and exists() returns false.

Testing your application

Files only: Storage::fake('media') works as usual.

Admin API: give the manager a connection with a mocked HTTP client:

use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Siberfx\RustFs\ConnectionConfig;
use Siberfx\RustFs\RustFsConnection;
use Siberfx\RustFs\RustFsManager;

it('provisions tenant storage', function () {
    $mock = new MockHandler([new Response(200, [], '{}'), new Response(200, [], '{}')]);

    app(RustFsManager::class)->setConnection('default', new RustFsConnection(
        ConnectionConfig::fromArray(['endpoint' => 'http://rustfs.test', 'key' => 'k', 'secret' => 's']),
        new Client(['handler' => HandlerStack::create($mock)]),
    ));

    // ... call your code and assert on the requests
});

S3 API: pass an Aws\S3\S3Client with an Aws\MockHandler as the third constructor argument. See docs/09.

RustFS behaviours this package handles

These were found by testing against a real server:

RustFS behaviour What the package does
add-user ignores its policy field users()->create(..., policy:) attaches the policy with a second call
/v3/info and /v3/storageinfo wrap their data in {"info": ...} info() / storageInfo() unwrap it (rawInfo() keeps everything)
Quota/usage calls return 503 until the scanner has seen a new bucket 503 responses are retried with backoff and Retry-After support
Notification target types are sub-systems (notify_webhook) Pass 'webhook'; the notify_/audit_ prefix is added
Module switch updates must include both flags The flag you don't pass keeps its current value
Object ACLs are accepted but ignored Use makePublic() / bucket policies (documented above)
/minio/admin expects encrypted bodies The plain-JSON /rustfs/admin prefix is used

Testing

composer test       # 150+ unit and feature tests, no server needed
composer analyse    # PHPStan level 6
composer format     # Laravel Pint

# End-to-end against a real RustFS (here: Laravel Herd)
RUSTFS_LIVE_ENDPOINT=http://127.0.0.1:9000 RUSTFS_LIVE_KEY=herd RUSTFS_LIVE_SECRET=secretkey \
    vendor/bin/pest --group=live

CI runs the unit suite on PHP 8.4 and 8.5 and the live suite against rustfs/rustfs:1.0.3 on every push.

Documentation

The in-depth guides are in docs/:

  1. Installation & configuration
  2. Filesystem disk
  3. Buckets
  4. Objects
  5. Admin: identity & access
  6. Admin: cluster operations
  7. Admin: security, replication & events
  8. Artisan commands
  9. Errors, testing & troubleshooting
  10. Admin API route map

Changelog & license

See CHANGELOG.md. Released under the MIT license.