| Home | Getting Started | Core Concepts | Helpers | Extensions | Repo |
This example accepts a single uploaded file, validates it, stores it on disk (with an optional S3-compatible mirror), and renders a confirmation. It uses PHP’s built-in $_FILES superglobal — Tiny doesn’t wrap it.
my-app/
├── app/
│ ├── controllers/
│ │ └── uploads.php
│ ├── models/
│ │ └── upload.php
│ └── views/
│ └── uploads/
│ ├── form.php
│ └── success.php
├── html/
│ └── static/uploads/ # local destination; served as /static/uploads/...
└── migrations/
└── 20240101_uploads.php
<?php
// migrations/20240101_uploads.php
class Uploads extends TinyMigration
{
public function up(): void
{
tiny::db()->execute("
CREATE TABLE uploads (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
filename VARCHAR(255) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
size_bytes INT NOT NULL,
url VARCHAR(500) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
");
}
public function down(): void
{
tiny::db()->execute("DROP TABLE IF EXISTS uploads");
}
}
Put limits in your .env:
TINY_UPLOAD_MAX_BYTES=10485760 # 10 MB
TINY_UPLOAD_DIR=/srv/my-app/html/static/uploads
TINY_UPLOAD_PUBLIC_PREFIX=/static/uploads
app/models/upload.php:
<?php
class UploadModel extends TinyModel
{
public const ALLOWED_MIME = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
];
public function store(array $file, int $userId): array
{
$this->validateFile($file);
$ext = self::ALLOWED_MIME[$file['type']];
$name = bin2hex(random_bytes(8)) . '.' . $ext;
$dir = rtrim($_SERVER['TINY_UPLOAD_DIR'] ?? '', '/');
$publicDir = rtrim($_SERVER['TINY_UPLOAD_PUBLIC_PREFIX'] ?? '/static/uploads', '/');
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$fullPath = "$dir/$name";
if (!move_uploaded_file($file['tmp_name'], $fullPath)) {
throw new \RuntimeException('move_uploaded_file failed');
}
$url = "$publicDir/$name";
// Optional: mirror to S3-compatible storage if configured.
if (!empty($_SERVER['TINY_S3_BUCKET'])) {
$url = tiny::spaces()->uploadFromDisk($fullPath, "uploads/$name");
}
$id = tiny::db()->insert('uploads', [
'user_id' => $userId,
'filename' => $name,
'mime_type' => $file['type'],
'size_bytes' => $file['size'],
'url' => $url,
]);
return tiny::db()->getOne('uploads', ['id' => $id]);
}
private function validateFile(array $file): void
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new \RuntimeException('upload error: ' . $file['error']);
}
$max = (int)($_SERVER['TINY_UPLOAD_MAX_BYTES'] ?? 10 * 1024 * 1024);
if ($file['size'] > $max) {
throw new \RuntimeException("file too large (max $max bytes)");
}
// Re-check MIME from contents, not the client-supplied header.
$detected = mime_content_type($file['tmp_name']);
if (!isset(self::ALLOWED_MIME[$detected])) {
throw new \RuntimeException("file type not allowed: $detected");
}
// Override the client value with the trusted one.
$file['type'] = $detected;
}
}
The MIME check uses mime_content_type() against the actual bytes — never trust the value the browser sends in $_FILES['…']['type'].
app/controllers/uploads.php:
<?php
class Uploads extends TinyController
{
public function get($request, $response)
{
$response->render('uploads/form');
}
public function post($request, $response)
{
if (!$request->isValidCSRF()) {
return $response->hasCSRFError();
}
$file = $_FILES['upload'] ?? null;
if (!$file) {
tiny::flash('toast')->set(['level' => 'error', 'message' => 'No file uploaded']);
return $response->redirect('/uploads');
}
try {
$upload = tiny::model('upload')->store($file, tiny::user()->id);
} catch (\Throwable $e) {
tiny::flash('toast')->set([
'level' => 'error',
'message' => $e->getMessage(),
]);
return $response->redirect('/uploads');
}
$response->render('uploads/success', ['upload' => $upload]);
}
}
app/views/uploads/form.php:
<?php Layout::main(['title' => 'Upload a file']); ?>
<h1>Upload a file</h1>
<?php $toast = tiny::flash('toast')->get(); ?>
<?php if ($toast): ?>
<div class="alert alert-<?= htmlspecialchars($toast['level']) ?>">
<?= htmlspecialchars($toast['message']) ?>
</div>
<?php endif ?>
<form method="POST" action="/uploads" enctype="multipart/form-data">
<?php tiny::csrf()->input(); ?>
<label>Choose a file
<input type="file" name="upload"
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
required>
</label>
<small>JPG, PNG, GIF, WebP, or PDF. Max 10 MB.</small>
<button type="submit">Upload</button>
</form>
<?php Layout::main(); ?>
app/views/uploads/success.php:
<?php Layout::main(['title' => 'Upload complete']); ?>
<h1>Upload complete</h1>
<p>Stored as <code><?= htmlspecialchars($upload->filename) ?></code>
(<?= number_format($upload->size_bytes) ?> bytes).</p>
<p><a href="<?= htmlspecialchars($upload->url) ?>" target="_blank">View file</a></p>
<p><a href="/uploads">Upload another</a></p>
<?php Layout::main(); ?>
PHP enforces upload limits in php.ini before your code ever runs:
upload_max_filesize = 10M
post_max_size = 12M # must be ≥ upload_max_filesize, with headroom for fields
max_file_uploads = 20
If you let an oversize file through these, $_FILES['upload']['error'] will be UPLOAD_ERR_INI_SIZE, which is what the validateFile() check catches.
When TINY_S3_BUCKET is set, the model above uploads the moved file to S3-compatible storage via tiny::spaces()->uploadFromDisk(). If you want to skip the local disk entirely, stream straight from tmp_name:
$url = tiny::spaces()->uploadFromDisk($file['tmp_name'], "uploads/$name");
unlink($file['tmp_name']); // optional; PHP cleans tmp on shutdown anyway
html/ when you genuinely want them served directly.php.ini and your validator. Defense in depth.imagecreatefromjpeg + imagejpeg round-trip drops it.