Para mí, el hecho de tener un buen control sobre el componente que nos permite hacer Upload de los ficheros es muy importante, más, cuando quieres que sean un ficheros con extensión determinada y que si son imágenes las puedas redimensionar y ajustar a las características que precises.
Este ejercicio va de eso, subir ficheros de forma elegante y determinando en número de ellos que se pueden subir en la misma transacción.
Objetivo
Disponer de un componente que permita subir ficheros de forma eficiente (asíncrona) y fácil (arrastrar), controlando características de los ficheros y redimensionando o convirtiendo características de los ficheros.
DEMO: https://fhumanes.com/multi-files/

Solución Técnica
Cómo habéis podido ver en el logo de la página, lo que he utilizado es el componente «Filepond». Tiene una excelente documentación y ejemplos, en donde podréis ver todas las características que soporta.
En este ejemplo se han utilizado muy pocas. Además de aprender esas pocas características, podéis utilizar el mismo para probar todas las demás. Creo que esos ajusten se pueden hacer rápidamente y si tenéis algún problema, podéis consultarme y yo intento hacer los ajustes y explicarlos.
También he utilizado Felte, para los formularios, y Zod, para las validaciones. Estos componentes ya los hemos utilizado en ejemplos anteriores.
Para el Front-End, he utilizado Svelte 5 y para el Back-End he utilizado PHP SLIM 4.
El algoritmo que utiliza es:
- Los ficheros se quedan en un array en Front-End, manteniendo todas sus características.
- Los ficheros van subiendo de forma asíncrona al Server y los deja en un directorio «/temp» on un nombre de fichero único, que sube al Front-End, cuando la subida del fichero se ha completado.
- Si se suben más ficheros o se elimina alguno de los subidos, se le va comunicando al Server y va realizando las operaciones de añadir o eliminar, ficheros del directorio «/temp».
- Cuando se indica que el formulario se ha completado, entonces se valida los campos y en este caso, se obliga a que al menos, se haya subido un fichero. Al Server se pasa toda la información del Array de los ficheros, para que haga las operaciones que corresponda. En este caso, pasa los ficheros del directorio «/temp» al directorio «/final».
Os muestro los ficheros más significativos.
Svelte 5:
<script>
import { hostServer } from "./lib/Host-server.js";
import axios from "axios";
import { createForm } from "felte";
import { validator } from "@felte/validator-zod";
import { z } from "zod";
const schema = z.object({
files: z
.any()
.refine((files) => files && files.length > 0, "Debes seleccionar al menos una imagen")
.refine(
(files) =>
[...files].every((f) =>
["image/jpeg", "image/png", "image/webp", "image/gif"].includes(f.type)
),
"Solo se permiten imágenes JPG, PNG, WEBP o GIF"
),
});
const { form, errors, isSubmitting, data } = createForm({
extend: validator({ schema }),
onSubmit: async (values) => {
const formData = new FormData();
for (const file of values.files) {
formData.append("files[]", file);
}
try {
const res = await axios.post(hostServer() + "multi-files-server/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (progressEvent) => {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
console.log("Progreso:", percent + "%");
},
});
console.log("Respuesta del servidor:", res.data);
} catch (err) {
console.error("Error subiendo imágenes:", err);
}
},
});
</script>
<form id="upload-form" use:form class="p-4 space-y-4">
<input
type="file"
name="files"
multiple
accept="image/*"
class="file-input file-input-bordered w-full"
/>
{#if errors.files}
<p class="text-red-500">{errors.files}</p>
{/if}
</form>
<button class="btn btn-primary" form="upload-form" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Subiendo..." : "Subir imágenes"}
</button>
<button class="btn btn-primary" form="upload-form" type="submit">
Subir imágenes
</button>
<script>
import axios from "axios";
import FileUploader from "./lib/FileUploader.svelte";
import { hostServer } from "./lib/Host-server.js";
import { createForm } from "felte";
import { validator } from "@felte/validator-zod";
import { z } from "zod";
let uploader;
// Validación de ZOD
const schema = z.object({
title: z.string().min(1, "El título es obligatorio"),
files: z.array(
z.object({
tempName: z.string(),
originalName: z.string(),
mimeType: z.string(),
size: z.number()
})
).min(1, "Debes subir al menos un archivo")
});
const { form, errors, setFields , reset } = createForm({
initialValues: {
title: "",
files: []
},
extend: validator({ schema }),
onSubmit: async (values) => {
await axios.post(
hostServer() + "multi-files-server/save-form",
values,
{ headers: { "Content-Type": "application/json" } }
);
if (uploader) uploader.reset();
reset(); // Borrado de campos y del switch de campos "tocados"
}
});
// 🔥 ESTA ES LA FUNCIÓN QUE PASAMOS AL HIJO
function updateFilesFromChild(newFiles) {
console.log("ficheros recibidos: ",newFiles);
setFields("files", newFiles);
}
</script>
<div class="bg-base-200 w-110 m-4 p-4 border-primary-content" >
<form use:form>
<div class="form-control p-2 ">
<label class="label bold" for="title">Título: </label>
<input name="title" id="title" class="input input-bordered m-2" />
{#if $errors.title}
<p class="text-red-500 text-sm">{$errors.title[0]}</p>
{/if}
<FileUploader
bind:this={uploader}
onFilesChanged={updateFilesFromChild}
/>
{#if $errors.files}
<p class="text-red-500 text-sm">{$errors.files[0]}</p>
{/if}
</div>
<button class="btn btn-primary" type="submit">Guardar</button>
</form>
</div>
<script>
import { hostServer } from "./Host-server.js";
import axios from "axios";
import FilePond from "svelte-filepond";
import { registerPlugin } from "filepond";
import FilePondPluginImagePreview from "filepond-plugin-image-preview";
import FilePondPluginFileValidateSize from "filepond-plugin-file-validate-size";
import FilePondPluginFileValidateType from "filepond-plugin-file-validate-type";
import "filepond/dist/filepond.min.css";
import "filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css";
registerPlugin(
FilePondPluginImagePreview,
FilePondPluginFileValidateSize,
FilePondPluginFileValidateType
);
// SVELTE 5: recogemos props así
let { onFilesChanged } = $props();
let uploadedFiles = []; // Array interno para mantener el conjunto de ficheros subidos
let pond; // ref interna al FilePond
// método que podrá llamar el padre
export function reset() {
uploadedFiles.length = 0; // vacía el array que viene del padre (sin reasignar)
if (pond) pond.removeFiles();
}
const server = {
process: (fieldName, file, metadata, load, error, progress, abort) => {
const formData = new FormData();
formData.append("filepond", file);
const source = axios.CancelToken.source();
axios.post(
hostServer() + "multi-files-server/upload-temp",
formData,
{
cancelToken: source.token,
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (e) => {
progress(e.lengthComputable, e.loaded, e.total);
}
}
)
.then((res) => {
const data = res.data;
uploadedFiles.push({
tempName: data.tempName,
originalName: data.originalName,
mimeType: data.mimeType,
size: data.size
});
// 🔥 Notificamos al padre
onFilesChanged(uploadedFiles);
load(data.tempName);
})
.catch(() => {
error("Error subiendo archivo");
});
return {
abort: () => {
source.cancel("Upload cancelado");
abort();
}
};
},
revert: (uniqueFileId, load) => {
axios.post(
hostServer() + "multi-files-server/delete-temp",
{ file: uniqueFileId },
{ headers: { "Content-Type": "application/json" } }
)
.finally(() => {
const index = uploadedFiles.findIndex(f => f.tempName === uniqueFileId);
if (index !== -1) uploadedFiles.splice(index, 1);
// 🔥 Notificamos al padre
onFilesChanged(uploadedFiles);
load();
});
}
};
/*
// 🔥 OPCIONAL: ocultar créditos de FilePond
const enlace = document.querySelector('a.filepond--credits');
if (enlace) {
enlace.style.display = 'none'
}
*/
</script>
<!-- 🔥 name="" evita que FilePond cree "filepond" -->
<FilePond
name=""
bind:this={pond}
allowMultiple={true}
maxFiles={10}
acceptedFileTypes={["image/*"]}
server={server}
labelIdle="Arrastra tus imágenes o <span class='filepond--label-action'>búscalas</span>"
/>
<style>
/*ocultamos creditos */
:global(.filepond--root .filepond--credits) {
display: none !important;
}
</style>
export function hostServer() {
let appHost = window.location.hostname;
if (appHost === 'localhost') {
return "http://localhost/"
} else {
return "https://fhumanes.com/"
}
}
En el fichero (1) tenemos un ejemplo para subir un único fichero en el formulario. Este ejemplo no se ve en la Demo.
En fichero (2) disponemos del formulario para subir múltiples archivos. Es el ejemplo que se muestra en la Demo.
En fichero (3) tenemos las funciones y configuración del «Filemond», que se utiliza en los formularios.
En fichero (4) lo que obtenemos es el dominio al que debe comunicarse con el proceso del Back-End.
PHP SLIM 4:
<?php
use Slim\Factory\AppFactory;
require __DIR__ . '/libs/autoload.php';
$app = AppFactory::create();
$app->setBasePath('/multi-files-server');
// Permitir JSON
$app->addBodyParsingMiddleware();
// CORS
(require __DIR__ . '/middleware.php')($app);
// Incluir rutas
(require __DIR__ . '/router.php')($app);
$app->run();
<?php
use Slim\App;
return function (App $app) {
// Middleware general CORS
$app->add(function ($request, $handler) {
if ($request->getMethod() === 'OPTIONS') {
$response = new \Slim\Psr7\Response();
return $response
/* ->withHeader('Access-Control-Allow-Origin', 'http://localhost:5173') */
->withHeader('Access-Control-Allow-Origin', '*')
->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->withHeader('Access-Control-Allow-Credentials', 'true');
}
$response = $handler->handle($request);
return $response
->withHeader('Access-Control-Allow-Origin', '*')
->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->withHeader('Access-Control-Allow-Credentials', 'true');
});
};
<?php
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\App;
return function (App $app) {
$app->post('/upload', function (Request $request, Response $response) {
$uploadedFiles = $request->getUploadedFiles();
$files = $uploadedFiles['files'] ?? [];
$result = [];
$debug = [];
foreach ($files as $file) {
$debug[] = [
"name" => $file->getClientFilename(),
"error" => $file->getError()
];
if ($file->getError() === UPLOAD_ERR_OK) {
$filename = moveUploadedFile(__DIR__ . '/uploads', $file);
$result[] = [
"original" => $file->getClientFilename(),
"saved_as" => $filename
];
}
}
$response->getBody()->write(json_encode([
"status" => "ok",
"files" => $result,
"debug" => $debug
]));
return $response->withHeader('Content-Type', 'application/json');
});
/* - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
$app->post('/upload-temp', function (Request $request, Response $response) {
$uploadedFiles = $request->getUploadedFiles();
$file = $uploadedFiles['filepond'] ?? null;
if (!$file || $file->getError() !== UPLOAD_ERR_OK) {
$response->getBody()->write(json_encode(["error" => "Upload failed"]));
return $response->withHeader('Content-Type', 'application/json');
}
$tempDir = __DIR__ . '/uploads/temp';
if (!is_dir($tempDir)) {
mkdir($tempDir, 0777, true);
}
$originalName = $file->getClientFilename();
$mimeType = $file->getClientMediaType();
$size = $file->getSize();
$extension = pathinfo($originalName, PATHINFO_EXTENSION);
$tempName = uniqid('temp_', true) . '.' . $extension;
$file->moveTo($tempDir . '/' . $tempName);
$response->getBody()->write(json_encode([
"tempName" => $tempName,
"originalName" => $originalName,
"mimeType" => $mimeType,
"size" => $size
]));
return $response->withHeader('Content-Type', 'application/json');
});
/* - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
$app->post('/delete-temp', function (Request $request, Response $response) {
$data = json_decode($request->getBody()->getContents(), true);
$file = $data['file'] ?? null;
if ($file) {
$path = __DIR__ . '/uploads/temp/' . $file;
if (file_exists($path)) {
unlink($path);
}
}
$response->getBody()->write(json_encode(["status" => "ok"]));
return $response->withHeader('Content-Type', 'application/json');
});
/* - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
$app->post('/save-form', function (Request $request, Response $response) {
$data = $request->getParsedBody();
$files = $data['files'] ?? [];
$finalDir = __DIR__ . '/uploads/final';
if (!is_dir($finalDir)) {
mkdir($finalDir, 0777, true);
}
foreach ($files as $fileInfo) {
$tempPath = __DIR__ . '/uploads/temp/' . $fileInfo['tempName'];
$finalPath = $finalDir . '/' . $fileInfo['tempName'];
if (file_exists($tempPath)) {
rename($tempPath, $finalPath);
}
// Aquí puedes guardar en BD:
// $fileInfo['originalName']
// $fileInfo['mimeType']
// $fileInfo['size']
}
$response->getBody()->write(json_encode(["status" => "ok"]));
return $response->withHeader('Content-Type', 'application/json');
});
};
// Función auxiliar
function moveUploadedFile($directory, $uploadedFile)
{
if (!is_dir($directory)) {
mkdir($directory, 0777, true);
}
$extension = pathinfo($uploadedFile->getClientFilename(), PATHINFO_EXTENSION);
$basename = bin2hex(random_bytes(8));
$filename = sprintf('%s.%s', $basename, $extension);
$uploadedFile->moveTo($directory . DIRECTORY_SEPARATOR . $filename);
return $filename;
}
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ %{ENV:BASE}index.php [QSA,L]
En el fichero (1) Es el fichero que se ejecuta en cada una de las peticiones al server y es él el que nos relaciona y ejecuta, el resto de los ficheros.
En el fichero (2) los que se configura son las peticiones que admite y desde qué cliente (IP o dominio) se admiten. En este caso, desde cualquier sitio.
En el fichero (3) identifica todas las peticiones que se pueden hacen, valida los parámetros de las peticiones, y ejecuta (crea o mueve ficheros), de acuerdo al petición.
En el fichero (4) se especifica que todas ls peticiones las ejecute el fichero «index.php».
Si habéis visto otros ejemplos mío de PHP SLIM, veréis que no se ha utiizado los mismos ficheros que he utilizado otras veces. En este caso, Copilot me ofreción esta solución y he creido que os podría servir ver otras formas de escribir estos servicios.
Como siempre, os dejo los fuentes para que los descarguéis y probéis en vuestros equipos.
Espero que os guste y os sea útil y para cualquier dificultad, contactar conmigo y gustosamente os ayudaré a lo que pueda.