nks-hub/nette-cloudflare-r2

Cloudflare R2 storage integration for Nette Framework with full S3-compatible API support

Installs: 2

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/nks-hub/nette-cloudflare-r2

v1.0.0 2026-01-23 22:06 UTC

This package is auto-updated.

Last update: 2026-01-24 08:40:44 UTC


README

PHP Version Nette Version License

Cloudflare R2 storage integration for Nette Framework with full S3-compatible API support.

Features

  • 🚀 Full S3-compatible API - Upload, download, delete, copy, list objects
  • 📦 Multipart uploads - Automatic chunked upload for large files
  • 🔗 Presigned URLs - Generate temporary access URLs (up to 7 days)
  • 🎯 Nette integration - DI extension, FileUpload support, Tracy panel
  • 💾 Storage classes - Standard and Infrequent Access support
  • 🔄 Lifecycle rules - Automatic expiration and transitions
  • 🌐 Cloudflare API - Custom domains, event notifications, metrics
  • 🛡️ Type-safe - Full PHP 7.4+ / 8.x support with strict types

Requirements

  • PHP 7.4 or higher
  • Nette Framework 3.x
  • Cloudflare R2 account with API credentials

Installation

composer require nks-hub/nette-cloudflare-r2

Configuration

Basic Setup

Register the extension in your config.neon:

extensions:
    r2: NksHub\NetteCloudflareR2\DI\CloudflareR2Extension

r2:
    accountId: 'your-account-id'
    accessKeyId: 'your-access-key-id'
    secretAccessKey: 'your-secret-access-key'
    defaultBucket: 'my-bucket'

Full Configuration

r2:
    # Required credentials
    accountId: %env.R2_ACCOUNT_ID%
    accessKeyId: %env.R2_ACCESS_KEY_ID%
    secretAccessKey: %env.R2_SECRET_ACCESS_KEY%
    defaultBucket: 'my-bucket'

    # Optional: Custom domain for public URLs
    publicUrl: 'https://cdn.example.com'

    # Optional: Jurisdictional restriction (eu, fedramp)
    jurisdiction: eu

    # Upload defaults
    upload:
        storageClass: STANDARD          # STANDARD or STANDARD_IA
        cacheControl: 'max-age=31536000'
        chunkSize: 8388608              # 8MB
        autoMultipart: true

    # Named buckets (optional)
    buckets:
        images:
            name: 'my-images-bucket'
            publicUrl: 'https://images.example.com'
        backups:
            name: 'my-backups-bucket'
            storageClass: STANDARD_IA

    # Debug options
    tracy: %debugMode%
    logging: false

Usage

Basic Operations

use NksHub\NetteCloudflareR2\Client\R2Client;

class MyPresenter extends Nette\Application\UI\Presenter
{
    public function __construct(
        private R2Client $r2
    ) {}

    public function actionUpload(): void
    {
        // Upload string content
        $url = $this->r2->upload('path/to/file.txt', 'Hello World!');

        // Upload from local file
        $url = $this->r2->uploadFromPath('images/photo.jpg', '/local/path/photo.jpg');

        // Download content
        $content = $this->r2->get('path/to/file.txt');

        // Download to local file
        $this->r2->download('images/photo.jpg', '/local/destination.jpg');

        // Delete
        $this->r2->delete('path/to/file.txt');

        // Check existence
        if ($this->r2->exists('path/to/file.txt')) {
            // File exists
        }

        // Get metadata
        $metadata = $this->r2->getMetadata('path/to/file.txt');
        echo $metadata->getSize();
        echo $metadata->getContentType();
        echo $metadata->getFormattedSize(); // "1.5 MB"
    }
}

File Upload Integration

use Nette\Http\FileUpload;
use NksHub\NetteCloudflareR2\Client\R2Client;

class GalleryPresenter extends Nette\Application\UI\Presenter
{
    public function __construct(
        private R2Client $r2
    ) {}

    public function handleUploadPhoto(): void
    {
        /** @var FileUpload $file */
        $file = $this->getHttpRequest()->getFile('photo');

        if ($file && $file->isOk() && $file->isImage()) {
            // Upload with auto-generated filename
            $url = $this->r2->uploadFile($file, 'gallery/' . $this->user->id);

            // Save URL to database
            $this->galleryRepository->insert([
                'user_id' => $this->user->id,
                'url' => $url,
            ]);

            $this->flashMessage('Photo uploaded successfully');
        }

        $this->redirect('this');
    }
}

Upload Options

use NksHub\NetteCloudflareR2\Storage\UploadOptions;
use NksHub\NetteCloudflareR2\Storage\StorageClass;

// Create options
$options = UploadOptions::create()
    ->withContentType('image/jpeg')
    ->withCacheControl('max-age=86400')
    ->withStorageClass(StorageClass::INFREQUENT_ACCESS)
    ->withMetadata(['author' => 'John Doe']);

$url = $r2->upload('photo.jpg', $content, $options);

Presigned URLs

// Temporary download URL (1 hour)
$presignedUrl = $r2->getPresignedUrl('private/document.pdf', 3600);
echo $presignedUrl->getUrl();
echo $presignedUrl->getExpiresAt()->format('Y-m-d H:i:s');

// Temporary upload URL
$uploadUrl = $r2->getPresignedUploadUrl('uploads/new-file.jpg', 600);

// Check if expired
if ($presignedUrl->isExpired()) {
    // Generate new URL
}

Listing Objects

// List with pagination
$result = $r2->list('images/', maxKeys: 100);
foreach ($result['objects'] as $object) {
    echo $object->getKey();
    echo $object->getSize();
}

if ($result['isTruncated']) {
    // Get next page
    $nextResult = $r2->list('images/', 100, $result['nextToken']);
}

// List all (auto-pagination)
foreach ($r2->listAll('images/') as $object) {
    echo $object->getKey();
}

// Count objects
$count = $r2->count('images/');

Multiple Buckets

use NksHub\NetteCloudflareR2\Client\R2ClientFactory;

class MyService
{
    public function __construct(
        private R2Client $r2,           // Default bucket
        private R2ClientFactory $factory
    ) {}

    public function uploadToImages(string $content): string
    {
        return $this->factory->create('images')->upload('file.jpg', $content);
    }

    public function uploadToBackups(string $content): string
    {
        return $this->factory->create('backups')->upload('backup.zip', $content);
    }
}

Copy and Metadata

// Copy object
$newUrl = $r2->copy('original.jpg', 'copy.jpg');

// Update metadata
$r2->setMetadata('file.jpg', ['version' => '2']);

// Change storage class
$r2->changeStorageClass('archive.zip', StorageClass::INFREQUENT_ACCESS);

Multipart Upload (Large Files)

// Automatic multipart for large files (>100MB by default)
$url = $r2->upload('large-file.zip', $largeContent);

// Manual multipart upload
$multipart = $r2->multipart();
$uploadId = $multipart->create('huge-file.zip');

$parts = [];
$partNumber = 1;
foreach ($chunks as $chunk) {
    $parts[] = $multipart->uploadPart('huge-file.zip', $uploadId, $partNumber++, $chunk);
}

$multipart->complete('huge-file.zip', $uploadId, $parts);

// With progress tracking
use NksHub\NetteCloudflareR2\Upload\ChunkedUploader;

$uploader = new ChunkedUploader($r2);
$uploader->onProgress(function (int $uploaded, int $total) {
    echo round($uploaded / $total * 100) . '%';
});
$url = $uploader->upload('huge-file.zip', '/local/path/huge-file.zip');

Lifecycle Rules

// Add expiration rule (delete after 90 days)
$r2->lifecycle()->addExpirationRule('logs/', days: 90);

// Transition to Infrequent Access after 30 days
$r2->lifecycle()->addTransitionRule(
    'archive/',
    days: 30,
    targetClass: StorageClass::INFREQUENT_ACCESS
);

// Abort incomplete multipart uploads after 7 days
$r2->lifecycle()->addAbortIncompleteMultipartRule(days: 7);

// Get current rules
$rules = $r2->lifecycle()->get();

// Remove a rule
$r2->lifecycle()->removeRule('expire-logs-90d');

Bucket Operations

// List buckets
$buckets = $r2->buckets()->list();

// Create bucket
$r2->buckets()->create('new-bucket', locationHint: 'weur');

// Delete bucket
$r2->buckets()->delete('old-bucket');

// CORS configuration
$r2->buckets()->setCors([
    [
        'AllowedOrigins' => ['https://example.com'],
        'AllowedMethods' => ['GET', 'PUT', 'POST'],
        'AllowedHeaders' => ['*'],
        'MaxAgeSeconds' => 3600,
    ],
]);

Cloudflare API (Extended Features)

use NksHub\NetteCloudflareR2\Api\CloudflareApi;

$api = new CloudflareApi($accountId, $apiToken);

// Custom domains
$api->attachCustomDomain('my-bucket', 'cdn.example.com');
$api->listCustomDomains('my-bucket');

// Enable r2.dev public access
$api->setManagedDomain('my-bucket', enabled: true);

// Event notifications
$api->createEventNotification(
    'my-bucket',
    'queue-id',
    ['object-create', 'object-delete'],
    prefix: 'uploads/'
);

// Get metrics
$metrics = $api->getMetrics();

// Temporary credentials
$creds = $api->createTempCredentials(
    'my-bucket',
    permission: 'object-read-only',
    ttlSeconds: 3600
);

Stream Operations

use NksHub\NetteCloudflareR2\Upload\StreamUploader;
use NksHub\NetteCloudflareR2\Download\StreamDownloader;

// Upload from URL
$streamUploader = new StreamUploader($r2);
$url = $streamUploader->uploadFromUrl('image.jpg', 'https://example.com/image.jpg');

// Upload base64
$url = $streamUploader->uploadBase64('image.png', $base64Data);

// Stream download
$streamDownloader = new StreamDownloader($r2);
$streamDownloader->streamToOutput('file.pdf', $this->getHttpResponse(), 'document.pdf');

// Create FileResponse
$response = $streamDownloader->createFileResponse('file.pdf', 'download.pdf');
$this->sendResponse($response);

Tracy Debugger Panel

When tracy: true is enabled, you'll see R2 statistics in the Tracy bar:

  • Total operations count
  • Uploads/downloads/deletes
  • Bytes transferred
  • Error count
  • Configuration details

Storage Classes

Class Use Case Retrieval Fee
STANDARD Frequently accessed data None
STANDARD_IA Infrequently accessed data Yes
use NksHub\NetteCloudflareR2\Storage\StorageClass;

$options = UploadOptions::create()
    ->withStorageClass(StorageClass::INFREQUENT_ACCESS);

Error Handling

use NksHub\NetteCloudflareR2\Exception\R2Exception;
use NksHub\NetteCloudflareR2\Exception\ObjectException;
use NksHub\NetteCloudflareR2\Exception\AuthenticationException;
use NksHub\NetteCloudflareR2\Exception\RateLimitException;

try {
    $r2->get('non-existent-file.txt');
} catch (ObjectException $e) {
    // Object not found
    echo $e->getR2ErrorCode(); // 10007
} catch (AuthenticationException $e) {
    // Invalid credentials
} catch (RateLimitException $e) {
    // Rate limited (1 write/second per key)
    sleep($e->getRetryAfter());
} catch (R2Exception $e) {
    // Other R2 errors
}

R2 Pricing Benefits

Cloudflare R2 offers significant cost savings:

Feature R2 AWS S3
Storage $0.015/GB $0.023/GB
Egress $0 (FREE!) $0.09/GB
Class A ops $4.50/million $5.00/million
Class B ops $0.36/million $0.40/million

Free tier: 10 GB storage, 1M Class A ops, 10M Class B ops per month.

Testing

# Run tests
./vendor/bin/tester tests

# With coverage
./vendor/bin/tester tests -c php.ini --coverage coverage.html

License

MIT License. See LICENSE for details.

Credits