1. Go to this page and download the library: Download citomni/upload library. Choose the download type require.
2. Extract the ZIP file and open the index.php.
3. Add this code to the index.php.
<?php
require_once('vendor/autoload.php');
/* Start to develop here. Best regards https://php-download.com/ */
use CitOmni\Upload\Enum\UploadRejection;
use CitOmni\Upload\Exception\UploadRejectedException;
use CitOmni\Upload\Util\UploadedFiles;
class AvatarController extends BaseController {
public function update(): void {
// App-specific: the user and the user's opaque upload token.
$userId = $this->resolveUserId();
$uploadToken = $this->resolveUploadToken($userId);
$file = UploadedFiles::one($_FILES['avatar'] ?? null);
if ($file === null) {
$message = $this->app->upload->rejectionMessage(UploadRejection::NoFile);
// Render the form with $message (app-specific).
return;
}
try {
$stored = $this->app->upload->storeUpload($file, 'avatar', $uploadToken . '/profile');
} catch (UploadRejectedException $e) {
$message = $this->app->upload->rejectionMessage($e);
// Render the form with $message (app-specific).
return;
}
(new ReplaceAvatar($this->app))->execute($userId, $stored);
// Redirect (app-specific).
}
}
// CitOmni\Upload\Service\Upload, service id "upload"
final class Upload extends BaseService {
#[\NoDiscard]
public function storeUpload(array $file, string|array $profile, string $subdir = ''): array {}
public function checkUpload(array $file, string|array $profile): array {}
#[\NoDiscard]
public function storeUploads(array $files, string|array $profile, string $subdir = ''): array {}
#[\NoDiscard]
public function storeLocal(string $sourcePath, string|array $profile, string $subdir = '', ?string $originalName = null, bool $move = false): array {}
public function rejectionMessage(UploadRejectedException|UploadRejection $rejection): string {}
public function delete(string $storage, string ...$paths): void {}
public function deleteWithVariants(string $path, string|array $profile): void {}
public function cleanup(string $storage, string ...$paths): bool {}
public function cleanupWithVariants(string $path, string|array $profile): bool {}
public function cleanupStored(array ...$stored): bool {}
public function variantPath(string $path, string $variant, string|array $profile): string {}
public function absolutePath(string $storage, string $path): string {}
public function webPath(string $storage, string $path): string {}
}
$file = UploadedFiles::one($_FILES['document'] ?? null);
if ($file === null) {
$message = $this->app->upload->rejectionMessage(UploadRejection::NoFile);
// Render the form with $message (app-specific).
return;
}
try {
$checked = $this->app->upload->checkUpload($file, 'document_intake');
} catch (UploadRejectedException $e) {
$message = $this->app->upload->rejectionMessage($e);
// Render the form with $message (app-specific).
return;
}
// ['mime' => 'application/pdf', 'size' => 184321, 'original_name' => 'Faktura 2026.pdf', 'width' => null, 'height' => null]
$stream = \fopen($file['tmp_name'], 'rb');
// Hand $stream and $checked to the application's own storage protocol (app-specific).
$batch = $this->app->upload->storeUploads(UploadedFiles::many($_FILES['attachments'] ?? null), 'attachment', (string)$ownerId);
// ['stored' => list of results, 'rejected' => list of UploadRejectedException]
// CLI command (illustrative): the same Operation an HTTP adapter would use, with another intake.
$stored = $this->app->upload->storeLocal($path, 'archived_document', (string)$ownerId, move: true);
(new ArchiveDocument($this->app))->execute($ownerId, $stored);
$thumb = $this->app->upload->variantPath($row['avatar_path'], 'thumb', 'avatar');
// $baseUrl comes from the HTTP layer (app-specific).
$src = $baseUrl . '/' . $this->app->upload->webPath('public', $thumb);
final class ReplaceAvatar extends BaseOperation {
/**
* Point a user's profile at a newly stored avatar and discard the previous one.
*
* Behavior:
* - Swaps the path in one transaction and receives the previous path.
* - On persistence failure, the newly stored files are removed (best effort) and the error bubbles.
* - After a successful swap, the previous files are removed (best effort, logged by Upload on failure).
*
* @param int $userId Target user.
* @param array<string,mixed> $stored Result from Upload::storeUpload() or Upload::storeLocal().
* @return array{path:string} The stored main path.
*/
public function execute(int $userId, array $stored): array {
$repository = new UserProfileRepository($this->app);
$oldPath = null;
$swapped = false;
try {
$oldPath = $repository->swapAvatarPath($userId, $stored['path']);
$swapped = true;
} finally {
if (!$swapped) {
$this->app->upload->cleanupStored($stored);
}
}
if ($oldPath !== null) {
$this->app->upload->cleanupWithVariants($oldPath, 'avatar');
}
return ['path' => $stored['path']];
}
}
final class UserProfileRepository extends BaseRepository {
public function swapAvatarPath(int $userId, string $newPath): ?string {
return $this->app->db->transaction(function () use ($userId, $newPath): ?string {
$old = $this->app->db->fetchValue(
'SELECT avatar_path FROM user_profiles WHERE user_id = ? FOR UPDATE',
[$userId]
);
$this->app->db->execute(
'UPDATE user_profiles SET avatar_path = ? WHERE user_id = ?',
[$newPath, $userId]
);
return $old === null ? null : (string)$old;
});
}
}
// Controller action body (illustrative), with the imports of the Quick start controller.
$batch = $this->app->upload->storeUploads(UploadedFiles::many($_FILES['attachments'] ?? null), 'attachment', (string)$ownerId);
if ($batch['rejected'] !== []) {
// All or nothing: the files that were accepted must not linger.
$this->app->upload->cleanupStored(...$batch['stored']);
$messages = [];
foreach ($batch['rejected'] as $e) {
$messages[] = $e->context['original_name'] . ': ' . $this->app->upload->rejectionMessage($e);
}
// Render the form with $messages (app-specific).
return;
}
$result = (new AddAttachments($this->app))->execute($ownerId, $batch['stored']);
final class AddAttachments extends BaseOperation {
private const int MAX_FILES = 20;
/**
* Persist stored files for an owner entity within the per-owner limit.
*
* Behavior:
* - Persists all files in one transaction, or none when the limit would be exceeded.
* - When nothing is persisted, the stored files are removed (best effort).
*
* @param int $ownerId Owning entity.
* @param list<array<string,mixed>> $storedFiles Store results, e.g. Upload::storeUploads()['stored'].
* @return array{ok:bool, reason:?string, ids:list<int>} Outcome and the new ids.
*/
public function execute(int $ownerId, array $storedFiles): array {
$repository = new AttachmentRepository($this->app);
$ids = null;
try {
$ids = $repository->insertWithinLimit($ownerId, $storedFiles, self::MAX_FILES);
} finally {
if ($ids === null) {
// Limit exceeded or persistence failed: the stored files must not linger.
$this->app->upload->cleanupStored(...$storedFiles);
}
}
return $ids === null
? ['ok' => false, 'reason' => 'limit_exceeded', 'ids' => []]
: ['ok' => true, 'reason' => null, 'ids' => $ids];
}
}
// Stops at the first rejection, so nothing after it is processed.
$stored = [];
$complete = false;
try {
foreach (UploadedFiles::many($_FILES['attachments'] ?? null) as $file) {
$stored[] = $this->app->upload->storeUpload($file, 'attachment', (string)$ownerId);
}
$complete = true;
} catch (UploadRejectedException $e) {
$message = $this->app->upload->rejectionMessage($e);
// Render the form with $message (app-specific).
return;
} finally {
if (!$complete) {
// A rejection or a fault part-way: the files stored so far must not linger.
$this->app->upload->cleanupStored(...$stored);
}
}
// Controller (illustrative): authorization for $documentId is already enforced.
$row = (new ArchivedDocumentRepository($this->app))->findById($documentId);
$this->app->response->download(
$this->app->upload->absolutePath('private', $row['storage_path']),
$row['original_name']
);
// Operation (illustrative): a failed deletion throws, so the record is never marked falsely.
$this->app->upload->deleteWithVariants($row['storage_path'], 'archived_document');
$repository->markPurged($row['id']);
// Controller action body (illustrative), with the imports of the Quick start controller.
$file = UploadedFiles::one($_FILES['document'] ?? null);
if ($file === null) {
$message = $this->app->upload->rejectionMessage(UploadRejection::NoFile);
// Render the form with $message (app-specific).
return;
}
try {
$checked = $this->app->upload->checkUpload($file, 'document_intake');
} catch (UploadRejectedException $e) {
$message = $this->app->upload->rejectionMessage($e);
// Render the form with $message (app-specific).
return;
}
$stream = \fopen($file['tmp_name'], 'rb');
try {
// Commits the intent, then writes $stream under a key derived from it (app-specific).
$result = (new ReceiveDocument($this->app))->execute($ownerId, $checked['original_name'], $checked['mime'], $stream);
} finally {
\fclose($stream);
}
apache
# Example for public/uploads/.htaccess; adapt it to your server. Directives in
# .htaccess ry from the
# PHP handler in the virtual host instead.
<IfModule mod_php.c>
php_flag engine off
</IfModule>
# Deny dotfiles, including half-written .tmp files.
<FilesMatch "^\.">
Require all denied
</FilesMatch>
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
Header always set Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox"
</IfModule>
text
citomni/
├── kernel/
├── image/
└── upload/
bash
php tests/run.php
bash
php tests/mutation/mutate.php # All mutants.
php tests/mutation/mutate.php <filter> # Mutants whose id or label contains <filter>.
php tests/mutation/mutate.php --list # Validate the definitions and list them.
Loading please wait ...
Before you can download the PHP files, the dependencies should be resolved. This can take some minutes. Please be patient.