Skip to content

File Uploads

File uploads in Hyper work differently from traditional forms. When you bind a file input with data-bind (Datastar), the files are automatically encoded as base64 and included in the signals sent to your server. This approach works seamlessly with Hyper's reactive architecture while Laravel handles validation and storage.

How File Uploads Work

When a user selects a file:

  1. Datastar captures the file from the input element
  2. The file is converted to base64 encoding
  3. The base64 data is stored in the bound signal as an array
  4. When you submit the form, the base64 data is sent to Laravel in the signals
  5. Laravel validates and stores the file

This process happens automatically—you don't need to handle file encoding manually.

Basic File Upload

blade
<form @signals(['avatar' => null, 'errors' => []])
      data-on:submit__prevent="@postx('/profile/avatar')">

    <div>
        <label>Profile Picture</label>
        <input type="file"
               data-bind="avatar"
               accept="image/*" />
        <div data-error="avatar" class="text-red-500"></div>
    </div>

    <button type="submit">Upload</button>
</form>
php
public function uploadAvatar()
{
    signals()->validate([
        'avatar' => 'required|b64image|b64max:2048',
    ]);

    $path = signals()->store('avatar', 'avatars', 'public');

    auth()->user()->update(['avatar' => $path]);

    return hyper()->signals([
        'avatar' => null,
        'errors' => []
    ]);
}

Base64 Validation Rules

Hyper provides specialized validation rules for base64-encoded files:

b64file

Validates that the signal contains a valid base64-encoded file:

php
signals()->validate([
    'document' => 'required|b64file',
]);

b64image

Validates that the signal contains a valid base64-encoded image:

php
signals()->validate([
    'avatar' => 'required|b64image',
]);

This rule checks that the file is an image (JPEG, PNG, GIF, WebP, SVG).

b64max

Sets the maximum file size in kilobytes:

php
signals()->validate([
    'avatar' => 'required|b64image|b64max:2048', // Max 2MB
    'document' => 'nullable|b64file|b64max:5120', // Max 5MB
]);

b64dimensions

Validates image dimensions with the same syntax as Laravel's dimensions rule:

php
signals()->validate([
    'avatar' => 'required|b64image|b64dimensions:min_width=100,min_height=100,max_width=1000',
    'banner' => 'required|b64image|b64dimensions:width=1200,height=400',
    'thumbnail' => 'required|b64image|b64dimensions:ratio=16/9',
]);

Supported constraints:

  • min_width=100 - Minimum width in pixels
  • max_width=1000 - Maximum width in pixels
  • min_height=100 - Minimum height in pixels
  • max_height=1000 - Maximum height in pixels
  • width=500 - Exact width in pixels
  • height=500 - Exact height in pixels
  • ratio=16/9 - Aspect ratio

b64mimes

Validates file type by extension:

php
signals()->validate([
    'document' => 'required|b64file|b64mimes:pdf,doc,docx',
    'image' => 'required|b64image|b64mimes:jpg,png,gif',
]);

Storing Files

Using signals()->store()

The store() method saves base64 files to Laravel's storage system:

php
public function uploadAvatar()
{
    signals()->validate([
        'avatar' => 'required|b64image|b64max:2048',
    ]);

    // Store in storage/app/public/avatars
    $path = signals()->store('avatar', 'avatars', 'public');

    // $path is something like: avatars/file_abc123.jpg

    auth()->user()->update(['avatar' => $path]);

    return hyper()->signals(['avatar' => null, 'errors' => []]);
}

Parameters:

  • $signalKey - The name of the signal containing the base64 file
  • $directory - Directory within the disk (optional)
  • $disk - Laravel storage disk (default: 'public')
  • $filename - Custom filename (optional, auto-generated if not provided)

Using signals()->storeAsUrl()

Get the public URL immediately after storing:

php
$url = signals()->storeAsUrl('avatar', 'avatars', 'public');

// $url is something like: http://yourapp.com/storage/avatars/file_abc123.jpg

return hyper()->signals([
    'avatarUrl' => $url,
    'avatar' => null,
    'errors' => []
]);

Storing Multiple Files

Store multiple files at once with storeMultiple():

php
$paths = signals()->storeMultiple([
    'avatar' => 'avatars',
    'cover' => 'covers',
    'document' => 'documents',
], 'public');

// $paths = [
//     'avatar' => 'avatars/file_123.jpg',
//     'cover' => 'covers/file_456.jpg',
//     'document' => 'documents/file_789.pdf',
// ]

File Preview

Show a preview of the selected file using the @fileUrl action (Hyper):

blade
<div @signals(['profilePicture' => null])>
    <input type="file"
           data-bind="profilePicture"
           accept="image/*" />

    <!-- Show preview when file is selected -->
    <img data-show="$profilePicture !== null && $profilePicture.length > 0"
         data-attr:src="@fileUrl($profilePicture, {fallback: '/images/default-avatar.png'})"
         alt="Preview"
         class="w-32 h-32 object-cover rounded" />
</div>

The @fileUrl action converts the base64 signal to a data URL that can be used in src attributes.

@fileUrl Options

blade
<!-- Basic usage -->
<img data-attr:src="@fileUrl($profilePicture)" />

<!-- With fallback image -->\n<img data-attr:src="@fileUrl($profilePicture, {fallback: '/images/default.png'})" />

<!-- With custom MIME type for base64 -->
<img data-attr:src="@fileUrl($profilePicture, {defaultMime: 'image/png'})" />

Complete Upload Example

blade
<form @signals([
    'profile_picture' => null,
    'name' => '',
    'errors' => []
])
      data-on:submit__prevent="@postx('/profile/update')">

    <!-- File Input (hidden) -->
    <input id="profile-picture-input"
           type="file"
           class="hidden"
           data-bind="profile_picture"
           accept="image/*" />

    <!-- Empty State -->
    <div data-show="$profile_picture === null || $profile_picture.length === 0"
         class="border-2 border-dashed border-gray-300 rounded-lg p-6">
        <label for="profile-picture-input" class="cursor-pointer">
            <div class="text-center">
                <svg class="mx-auto h-12 w-12 text-gray-400">
                    <!-- Upload icon -->
                </svg>
                <p class="mt-2 text-sm text-gray-600">Click to upload profile picture</p>
                <p class="text-xs text-gray-500">PNG, JPG up to 2MB</p>
            </div>
        </label>
        <div data-error="profile_picture" class="text-red-500 mt-2"></div>
    </div>

    <!-- Preview State -->
    <div data-show="$profile_picture !== null && $profile_picture.length > 0" class="relative">
        <img data-attr:src="@fileUrl($profile_picture)"
             class="w-32 h-32 object-cover rounded-lg mx-auto"
             alt="Profile preview" />

        <!-- Remove Button -->
        <button type="button"
                class="absolute top-0 right-0 bg-red-500 text-white rounded-full p-1"
                data-on:click="$profile_picture = null">
            ×
        </button>

        <!-- Change Image Button -->
        <label for="profile-picture-input"
               class="block text-center mt-2 text-sm text-blue-600 cursor-pointer hover:underline">
            Change image
        </label>

        <div data-error="profile_picture" class="text-red-500 mt-2"></div>
    </div>

    <!-- Name Field -->
    <div class="mt-4">
        <label>Name</label>
        <input type="text" data-bind="name" />
        <div data-error="name" class="text-red-500"></div>
    </div>

    <button type="submit" class="mt-4 bg-blue-600 text-white px-4 py-2 rounded">
        Update Profile
    </button>
</form>
php
public function updateProfile()
{
    $validated = signals()->validate([
        'profile_picture' => 'nullable|b64image|b64max:2048|b64dimensions:min_width=100,min_height=100',
        'name' => 'required|string|max:255',
    ]);

    $user = auth()->user();

    // Store new profile picture if provided
    if (!empty($validated['profile_picture'])) {
        // Delete old avatar if exists
        if ($user->avatar) {
            Storage::disk('public')->delete($user->avatar);
        }

        $validated['avatar'] = signals()->store('profile_picture', 'avatars', 'public');
        unset($validated['profile_picture']);
    }

    $user->update($validated);

    return hyper()
        ->signals([
            'profile_picture' => null,
            'errors' => []
        ])
        ->js("showToast('Profile updated successfully', 'success')");
}

Multiple File Uploads

Allow users to select multiple files:

blade
<div @signals(['documents' => []])>
    <input type="file"
           data-bind="documents"
           multiple
           accept=".pdf,.doc,.docx" />

    <p data-text="$documents.length + ' files selected'"></p>

    <!-- Show list of selected files -->
    <template data-for="doc, index in $documents">
        <div class="flex items-center justify-between border p-2 mt-2">
            <span data-text="'Document ' + (index + 1)"></span>
            <button type="button"
                    data-on:click="$documents.splice(index, 1)"
                    class="text-red-600">
                Remove
            </button>
        </div>
    </template>

    <button type="button" data-on:click="@postx('/documents/upload')">
        Upload All
    </button>
</div>
php
public function uploadDocuments()
{
    signals()->validate([
        'documents' => 'required|array|min:1|max:5',
        'documents.*' => 'required|b64file|b64max:5120|b64mimes:pdf,doc,docx',
    ]);

    $paths = [];

    foreach (signals('documents') as $index => $document) {
        // Note: For multiple files, store them one at a time
        // You'll need to handle the array structure manually
        $paths[] = hyperStorage()->storeBase64Data(
            $document,
            'documents',
            'public',
            "document_{$index}_" . time() . ".pdf"
        );
    }

    return hyper()->signals([
        'documents' => [],
        'errors' => []
    ]);
}

Updating Existing Files

When editing records with existing files, pre-fill the signal with the file URL:

blade
<form @signals([
    'profile_picture' => $user->avatar ? asset('storage/' . $user->avatar) : null,
    'name' => $user->name,
    'errors' => []
])>
    <input type="file"
           data-bind="profile_picture"
           accept="image/*" />

    <!-- Show existing or new preview -->
    <img data-show="$profile_picture !== null"
         data-attr:src="@fileUrl($profile_picture, {fallback: '/images/default.png'})"
         class="w-32 h-32 object-cover rounded" />

    <input type="text" data-bind="name" />

    <button type="button" data-on:click="@postx('/profile/update')">
        Update
    </button>
</form>
php
public function updateProfile()
{
    $rules = [
        'name' => 'required|string|max:255',
    ];

    // Only validate profile_picture if it's a base64 file (newly uploaded)
    $profilePicture = signals('profile_picture');
    $isNewUpload = is_array($profilePicture) && isset($profilePicture[0]['contents']);

    if ($isNewUpload) {
        $rules['profile_picture'] = 'nullable|b64image|b64max:2048';
    }

    $validated = signals()->validate($rules);

    $user = auth()->user();

    // Handle new file upload
    if ($isNewUpload && !empty(signals('profile_picture'))) {
        if ($user->avatar) {
            Storage::disk('public')->delete($user->avatar);
        }

        $validated['avatar'] = signals()->store('profile_picture', 'avatars', 'public');
    } elseif (empty(signals('profile_picture'))) {
        // User removed the file
        if ($user->avatar) {
            Storage::disk('public')->delete($user->avatar);
        }
        $validated['avatar'] = null;
    }

    unset($validated['profile_picture']);

    $user->update($validated);

    return hyper()->signals(['errors' => []]);
}

Datastar creates a file signal as an array of objects with name, contents (base64), and mime properties (e.g., [{name: 'photo.jpg', contents: 'base64data...', mime: 'image/jpeg'}]). When a file is bound, check if the signal is an array with file objects to determine if the user uploaded a new file vs. keeping an existing one.

Performance Considerations

Base64 Encoding Overhead

Base64 encoding increases file size by approximately 33%. A 1MB file becomes about 1.33MB when encoded.

Recommendations:

  • Set reasonable file size limits (e.g., 2-5MB for images)
  • Use image compression before upload when possible
  • Consider the b64max validation rule to enforce limits

Large File Uploads

For very large files (>10MB), consider alternative approaches:

  • Direct uploads to S3 or similar cloud storage
  • Chunked uploads
  • Background job processing

Base64 uploads work well for typical use cases like profile pictures, documents, and small to medium images.

Browser Memory

Large files consume browser memory during encoding. Test your application with various file sizes to ensure smooth performance across devices.

Common Patterns

Avatar Upload with Preview and Removal

blade
<div @signals(['avatar' => $user->avatar ? asset('storage/' . $user->avatar) : null])>
    <input id="avatar-input" type="file" class="hidden" data-bind="avatar" accept="image/*" />

    <!-- Empty/Default State -->
    <div data-show="!$avatar || $avatar.length === 0">
        <label for="avatar-input" class="cursor-pointer">
            <div class="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center">
                <span class="text-gray-500">Upload</span>
            </div>
        </label>
    </div>

    <!-- Preview State -->
    <div data-show="$avatar && $avatar.length > 0" class="relative">
        <img data-attr:src="@fileUrl($avatar)" class="w-24 h-24 rounded-full object-cover" />
        <button type="button"
                class="absolute top-0 right-0 bg-red-500 text-white rounded-full w-6 h-6"
                data-on:click="$avatar = null">
            ×
        </button>
    </div>
</div>

PDF Upload with File Info

blade
<div @signals(['document' => null])>
    <input type="file" data-bind="document" accept=".pdf" />

    <div data-show="$document && $document.length > 0"
         class="mt-2 p-2 border rounded bg-gray-50">
        <p class="text-sm">PDF file selected</p>
        <button type="button"
                data-on:click="$document = null"
                class="text-red-600 text-sm">
            Remove
        </button>
    </div>

    <button type="button"
            data-on:click="@postx('/documents/upload')"
            data-attr:disabled="!$document || $document.length === 0">
        Upload Document
    </button>
</div>
blade
<div @signals(['images' => []])>
    <input type="file" data-bind="images" multiple accept="image/*" />

    <div class="grid grid-cols-3 gap-4 mt-4">
        <template data-for="image, index in $images">
            <div class="relative">
                <img data-attr:src="@fileUrl([$image])"
                     class="w-full h-32 object-cover rounded" />
                <button type="button"
                        class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6"
                        data-on:click="$images.splice(index, 1)">
                    ×
                </button>
            </div>
        </template>
    </div>

    <button type="button"
            data-on:click="@postx('/gallery/upload')"
            data-attr:disabled="$images.length === 0">
        Upload <span data-text="$images.length"></span> Images
    </button>
</div>