PHP places multipart uploads in temporary files and exposes metadata through FILES. The browser-supplied filename and content type are claims, not verified properties. First handle upload error codes and configured body limits; a request exceeding the total POST limit may leave expected fields absent rather than yielding a normal per-file error. Admit the file only after checking type from bytes, allowed dimensions or structure, size, and the caller's permission to attach it to the target record. Move it to a private, generated location, then create a database record that tracks scan status, owner, and retention. A public URL is a separate publication decision.
PHP Upload Tempfiles, Private Storage, and Lifecycle
Working case
An inspector attaches a 4.7 MiB site plan to permit 447. The form filename is `plan.pdf`, but the bytes are an executable archive. Another upload uses a path-like filename and a third exceeds the PHP request limit, leaving FILES empty. A naive handler moves files under the original name into the web root and records attachment success before scanning. The repaired path rejects the wrong byte type and missing upload state, generates an opaque storage key, moves only an admitted temporary upload into private storage, and marks it pending until inspection and malware checks finish. A failed database insert deletes the orphaned moved file or records a compensating cleanup task.
Implementation boundary
<?php
function uploadReady(array $upload, int $maximumBytes): bool {
return ($upload['error'] ?? null) === UPLOAD_ERR_OK
&& is_int($upload['size'] ?? null)
&& $upload['size'] > 0
&& $upload['size'] <= $maximumBytes
&& is_string($upload['tmp_name'] ?? null)
&& is_uploaded_file($upload['tmp_name']);
}Align reverse-proxy body limits, PHP post_max_size, upload_max_filesize, and application limits, then test their different failure shapes. Check UPLOAD_ERR_OK before using tmp_name, require is_uploaded_file when examining the temporary source, and use move_uploaded_file for the final move. Never derive a destination path directly from the client filename; retain a normalized display name separately. Inspect actual bytes with an appropriate decoder or file-info probe, and apply domain-specific content rules. Place the object outside the public web root or behind authenticated storage. Create a pending record before asynchronous scanning only if orphan repair is defined; make promotion to available state conditional on scan result and current authorization.
Cost and boundaries
Reading or hashing an admitted file of B bytes costs O(B) I/O; decoding a large image can require memory proportional to expanded pixels rather than compressed bytes. A temporary copy and final object may briefly coexist, so disk headroom must cover concurrent in-flight uploads. Malware scanning adds queue time and requires a pending state visible to users. Keeping every rejected file indefinitely is a storage and privacy problem; cleanup needs a retention clock. Measure post-limit failures, per-file errors, type mismatches, move failures, pending-scan age, orphan count, and private download authorization failures. Do not make synchronous scanning block all upload workers if the scanner has variable latency.
Failure trace
Upload a correct small PDF, a filename with traversal characters, a renamed archive, a zero-byte file, and a multipart body over each configured limit. Observe which cases produce FILES error codes and which produce an empty request. Force move_uploaded_file to fail, then force the database insert to fail after a successful move; both must leave a recorded recoverable state or no retained file. Try downloading while the attachment is pending, after it is rejected, and as another tenant. Upload two files with the same display name and verify neither overwrites the other. Revoke permit access between admission and final publication.
Verification
- Private storage keys do not derive from caller filenames.
- Pending or rejected files cannot be downloaded.
- A failed move or database write has a cleanup path.
Practice drill
Create a private permit-attachment intake for permit 447 with a 5 MiB application cap. Generate a random storage key, preserve a safe display name, and keep accepted files unavailable until a simulated scan marks them clean. Add an orphan cleanup pass for moved bytes whose database record was never committed. Test total request limit behavior separately from UPLOAD_ERR_INI_SIZE. Attach the same name twice and verify distinct keys. Require both current record permission and clean status to issue a download. Record the lifecycle transitions pending, available, rejected, and expired.
Decision note
Temporary upload bytes become a private managed asset only after validation, ownership, and cleanup rules are established.
Common Mistakes
- Trusting the client filename or content type as proof of format.
- Moving admitted bytes directly under a public path.
- Assuming every oversized POST creates a FILES error entry.
Related lessons
PHP Request, Session, and Persistence Boundaries; PHP Superglobal Input and Output Trust; PHP Session Rotation, Locking, and Logout; PHP PDO Transactions, Replay, and Query Identity; Safe File Upload Pipeline; Upload Intake Budgets and Storage Ownership; Tenant Scope in Cache and Background Work.
Apply and check
Build Project: PHP Permit Review Intake and review Web Development: PHP Request Contracts.
