Development
Submit a Joomla Form With a File Upload Over Ajax
Posting a form with a file attached, without reloading the page, is about a dozen lines of JavaScript in Joomla 6. FormData collects the fields and the file, fetch posts them, and a com_ajax plugin receives them. The work that deserves…
The form
An ordinary form. The only requirement is enctype, and Joomla's CSRF token — without it the request is rejected before your code runs.
<form id="application-form" enctype="multipart/form-data">
<input type="text" name="last_name" required>
<input type="text" name="first_name" required>
<input type="file" name="resume" accept=".pdf,.doc,.docx" required>
<button type="submit">Apply</button>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
The script
const form = document.getElementById('application-form');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const button = form.querySelector('button[type="submit"]');
button.disabled = true;
try {
const response = await fetch('/index.php?option=com_ajax&plugin=application&format=json', {
method: 'POST',
body: new FormData(form),
});
if (!response.ok) {
throw new Error(`Server returned ${response.status}`);
}
const result = await response.json();
// render success or validation errors from result
} catch (error) {
// show the visitor something useful
} finally {
button.disabled = false;
}
});
Two details that are easy to get wrong. Do not set a Content-Type header — the browser sets it along with the multipart boundary, and overriding it breaks the upload. And new FormData(form) collects the file and the token together, so nothing needs to be moved between forms.
Disabling the button matters more than it looks: without it a double click uploads twice, and on a slow connection people do double click.
The endpoint
A com_ajax plugin is the simplest place to receive this. Whatever you use, the checks are not optional:
use Joomla\CMS\Factory;
use Joomla\CMS\Session\Session;
use Joomla\Filesystem\File;
Session::checkToken() or die(json_encode(['error' => 'Invalid token']));
$file = Factory::getApplication()->getInput()->files->get('resume', null, 'raw');
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
return ['error' => 'Upload failed'];
}
$allowed = ['pdf', 'doc', 'docx'];
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allowed, true) || $file['size'] > 5 * 1024 * 1024) {
return ['error' => 'File type or size not accepted'];
}
$name = bin2hex(random_bytes(16)) . '.' . $ext;
File::upload($file['tmp_name'], JPATH_ROOT . '/media/images/legacy/' . $name, false, true);
Where uploads go wrong
The accept attribute is a convenience for the file picker, not a control — anything can be posted to your endpoint directly. The server-side extension and size checks are what actually protect you.
Never keep the submitted filename. It arrives from the client and can contain path separators, double extensions or characters your filesystem treats specially. Generate your own, as above, and store the original in the database if you need to show it back.
Finally, think about where the file lands. A directory under the web root that serves whatever it contains is a bad place for visitor uploads. Store them outside the web root, or make sure the directory refuses to execute anything.
Progress, if you need it
fetch gives you no upload progress. If a progress bar matters — large files, slow connections — XMLHttpRequest is still the tool for that, through its upload.onprogress event. That is the one case where the older API is not obsolete.