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:
- Datastar captures the file from the input element
- The file is converted to base64 encoding
- The base64 data is stored in the bound signal as an array
- When you submit the form, the base64 data is sent to Laravel in the signals
- Laravel validates and stores the file
This process happens automatically—you don't need to handle file encoding manually.
Basic File Upload
<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>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:
signals()->validate([
'document' => 'required|b64file',
]);b64image
Validates that the signal contains a valid base64-encoded image:
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:
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:
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 pixelsmax_width=1000- Maximum width in pixelsmin_height=100- Minimum height in pixelsmax_height=1000- Maximum height in pixelswidth=500- Exact width in pixelsheight=500- Exact height in pixelsratio=16/9- Aspect ratio
b64mimes
Validates file type by extension:
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:
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:
$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():
$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):
<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
<!-- 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
<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>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:
<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>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:
<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>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
b64maxvalidation 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
<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
<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>Image Gallery Upload
<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>
