Plik w PHP odbierasz z tablicy$_FILES, a formularz musi miećmethod="post"ienctype="multipart/form-data". Zanim go zapiszesz, sprawdź kod błędu (error), rozmiar i prawdziwy typ po zawartości (finfo), a potem przenieś go funkcjąmove_uploaded_file()pod własną, losową nazwą. Limity wielkości ustawiasz wphp.ini(upload_max_filesizeipost_max_size).
Upload pliku to jedna z pierwszych rzeczy, w której aplikacja przyjmuje od obcej osoby coś więcej niż kilka napisów, więc łatwo tu o błąd, który zamienia formularz w dziurę w bezpieczeństwie. Poniżej przechodzisz od najprostszego formularza do wersji, którą można wystawić użytkownikom. Dane tekstowe wysyłane razem z plikiem walidujesz tak jak w tekście o walidacji formularza w PHP, a upload widziany z przeglądarki (wybór pliku, podgląd, wstępna kontrola) opisuje osobny tekst o uploadzie plików po stronie przeglądarki. Tu skupiamy się na serwerze. Kod z artykułu został uruchomiony na PHP 8.4.
Jak działa upload plików w PHP?
Przeglądarka wysyła plik w żądaniu POST w formacie multipart/form-data, który dzieli ciało żądania na części: osobną dla każdego pola i osobną dla każdego pliku. Zanim wykona się Twój skrypt, PHP sam przyjmuje całe żądanie, zapisuje każdy plik w katalogu tymczasowym i wypełnia tablicę $_FILES. Twój kod dostaje więc gotowy plik na dysku serwera, a nie strumień danych.
Dwie konsekwencje warto zapamiętać od razu:
- plik tymczasowy znika po zakończeniu żądania. Jeśli go nie przeniesiesz (
move_uploaded_file()), po wszystkim nie zostanie po nim nic, - limity (rozmiar żądania, liczba plików) działają zanim Twój kod się uruchomi, więc przy ich przekroczeniu nie dostajesz wyjątku, tylko pustą lub niepełną tablicę.
Jak wygląda formularz do wysyłania pliku?
Formularz potrzebuje trzech rzeczy: metody post, atrybutu enctype="multipart/form-data" i pola type="file" z nazwą, pod którą odbierzesz plik:
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="text" name="title">
<input type="file" name="avatar" accept="image/png, image/jpeg, image/webp">
<button type="submit">Wyślij</button>
</form>
Atrybut accept tylko podpowiada przeglądarce, jakie pliki pokazać w oknie wyboru. Nie jest żadną ochroną: ktoś, kto wyśle żądanie ręcznie (curl, Postman), całkowicie go omija. Ten sam formularz bez enctype wysyła do serwera wyłącznie nazwę pliku jako zwykły tekst, a $_FILES zostaje puste. To najczęstsza przyczyna pytania „dlaczego mój plik się nie wgrywa”.
Skoro formularz zmienia stan po stronie serwera, dodaj do niego także token CSRF, tak jak do każdego innego formularza POST.
Co zawiera tablica $_FILES w PHP?
Dla pola avatar PHP tworzy wpis $_FILES['avatar'] z sześcioma kluczami:
| Klucz | Co zawiera | Czy można mu ufać? |
|---|---|---|
name | oryginalna nazwa pliku z komputera użytkownika | Nie, wysyła ją klient |
full_path | ścieżka względna wysłana przez przeglądarkę (od PHP 8.1) | Nie, wysyła ją klient |
type | typ MIME zadeklarowany przez przeglądarkę | Nie, wysyła go klient |
tmp_name | ścieżka pliku tymczasowego na serwerze | Tak, tworzy ją PHP |
error | kod błędu (UPLOAD_ERR_OK = 0 oznacza sukces) | Tak |
size | rozmiar w bajtach, policzony przez PHP | Tak (0 przy błędzie) |
Różnicę widać w prostym teście: plik tekstowy wysłany z nagłówkiem type=image/png i nazwą zdjecie.png trafił do $_FILES dokładnie z takimi wartościami, bo PHP nie sprawdza, czy deklaracja zgadza się z zawartością. Dlatego name i type służą co najwyżej do wyświetlenia, a decyzje opieramy na error, size i na tym, co faktycznie jest w pliku.
Jak odebrać i bezpiecznie zapisać plik w PHP?
Poniższy upload.php robi wszystko po kolei: sprawdza, czy żądanie w ogóle dotarło, czy przyszedł plik, czy PHP zgłosił błąd, jaki jest rozmiar i prawdziwy typ, a dopiero potem zapisuje plik pod własną nazwą:
<?php
declare(strict_types=1);
const UPLOAD_DIR = __DIR__ . '/../uploads';
const MAX_SIZE = 2 * 1024 * 1024; // 2 MB
const ALLOWED_TYPES = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
function uploadErrorMessage(int $code): string
{
return match ($code) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'Plik jest za duży.',
UPLOAD_ERR_PARTIAL => 'Plik dotarł tylko częściowo, spróbuj ponownie.',
UPLOAD_ERR_NO_FILE => 'Nie wybrano pliku.',
UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE, UPLOAD_ERR_EXTENSION => 'Błąd serwera podczas zapisu pliku.',
default => 'Nieznany błąd przesyłania.',
};
}
function fail(int $status, string $message): never
{
http_response_code($status);
echo json_encode(['error' => $message], JSON_UNESCAPED_UNICODE);
exit;
}
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
fail(405, 'Użyj metody POST.');
}
// Gdy żądanie przekroczy post_max_size, PHP zostawia $_POST i $_FILES puste
if (empty($_FILES) && empty($_POST) && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
fail(413, 'Żądanie jest puste albo większe niż limit post_max_size.');
}
$file = $_FILES['avatar'] ?? null;
if ($file === null || is_array($file['error'])) {
fail(400, 'Brakuje pola avatar z jednym plikiem.');
}
if ($file['error'] !== UPLOAD_ERR_OK) {
fail($file['error'] === UPLOAD_ERR_NO_FILE ? 400 : 413, uploadErrorMessage($file['error']));
}
if (!is_uploaded_file($file['tmp_name'])) {
fail(400, 'To nie jest plik przesłany przez HTTP.');
}
if ($file['size'] > MAX_SIZE) {
fail(413, 'Plik jest większy niż 2 MB.');
}
// Typ ustalamy po zawartości pliku, nie po $file['type'] ani rozszerzeniu
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!isset(ALLOWED_TYPES[$mime]) || getimagesize($file['tmp_name']) === false) {
fail(415, 'Dozwolone są tylko obrazy JPEG, PNG i WebP.');
}
// Własna nazwa: nazwa od klienta nigdy nie trafia na dysk
$name = bin2hex(random_bytes(16)) . '.' . ALLOWED_TYPES[$mime];
if (!move_uploaded_file($file['tmp_name'], UPLOAD_DIR . '/' . $name)) {
fail(500, 'Nie udało się zapisać pliku.');
}
http_response_code(201);
echo json_encode(['file' => $name, 'title' => $_POST['title'] ?? null], JSON_UNESCAPED_UNICODE);
Co z tego kodu jest najważniejsze:
$file['error'] !== UPLOAD_ERR_OKsprawdzasz zawsze pierwsze. Przy błędzietmp_namejest puste, asizerówne 0, więc dalsze kroki nie mają na czym działać.is_uploaded_file()potwierdza, że ścieżka pochodzi z prawdziwego uploadu, a nie z podstawionej wartości.move_uploaded_file()sprawdza to samo przy przenoszeniu.- Typ pliku ustala
finfona podstawie zawartości. Dodatkowegetimagesize()zwracafalsedla czegoś, co nie jest obrazem. - Nazwa na dysku to losowe 32 znaki hex plus rozszerzenie wybrane z listy dozwolonych typów, a nie z nazwy od użytkownika. Wysłany plik o nazwie
../../x.pngzapisał się jako zwykły<32 znaki>.pngw kataloguuploads.
Test na PHP 8.4 dał oczekiwane kody: poprawny PNG → 201, plik tekstowy z kodem <?php → 415 (nawet z podrobionym type=image/png), plik 3 MB przy limicie 2 MB → 413, GET → 405.
Co oznaczają kody błędów UPLOAD_ERR w PHP?
Klucz error to jedna ze stałych UPLOAD_ERR_*. Warto znać je z nazwy, bo to one mówią, dlaczego upload się nie udał:
| Stała | Wartość | Znaczenie | Typowa przyczyna |
|---|---|---|---|
UPLOAD_ERR_OK | 0 | brak błędu | |
UPLOAD_ERR_INI_SIZE | 1 | plik większy niż upload_max_filesize | domyślny limit to 2 MB |
UPLOAD_ERR_FORM_SIZE | 2 | plik większy niż ukryte pole MAX_FILE_SIZE w formularzu | rzadko używany mechanizm |
UPLOAD_ERR_PARTIAL | 3 | plik dotarł tylko częściowo | przerwane połączenie |
UPLOAD_ERR_NO_FILE | 4 | nie wybrano pliku | puste pole file |
UPLOAD_ERR_NO_TMP_DIR | 6 | brak katalogu tymczasowego | zła konfiguracja serwera |
UPLOAD_ERR_CANT_WRITE | 7 | nie udało się zapisać pliku na dysku | brak miejsca lub uprawnień |
UPLOAD_ERR_EXTENSION | 8 | zapis przerwało rozszerzenie PHP | rzadkie, konfiguracja serwera |
Wartości 1, 2 i 4 sprawdzono w teście (plik 3 MB przy limicie 2 MB daje error = 1, a MAX_FILE_SIZE=50 przy pliku 75 B daje error = 2). Dla UPLOAD_ERR_INI_SIZE zwróć użytkownikowi czytelny komunikat, bo to najczęstszy błąd, jaki zobaczy.
Jak sprawdzić typ pliku w PHP?
Są trzy sposoby i tylko jeden z nich jest wiarygodny:
| Sposób | Skąd bierze informację | Wiarygodność |
|---|---|---|
$_FILES['x']['type'] | nagłówek wysłany przez przeglądarkę | żadna |
rozszerzenie z name | nazwa wpisana przez użytkownika | żadna |
finfo (FILEINFO_MIME_TYPE) | pierwsze bajty pliku na serwerze | dobra, ale nie idealna |
finfo rozpoznaje typ po zawartości, więc poznaje text/x-php nawet w pliku nazwanym zdjecie.png. Rozszerzenie fileinfo jest włączone w standardowej instalacji PHP. Przy typowych formatach dostajesz konkretne wartości:
| Plik | Typ zwrócony przez finfo |
|---|---|
| PNG | image/png |
application/pdf | |
| ZIP | application/zip |
| DOCX | application/vnd.openxmlformats-officedocument.wordprocessingml.document |
| CSV (mały plik testowy) | text/csv |
| SVG | image/svg+xml |
Z tej tabeli wynikają dwa praktyczne wnioski.
Trzymaj listę dozwolonych typów, nie prefiks. Warunek str_starts_with($mime, 'image/') przepuści SVG, a plik SVG może zawierać <script>. W teście finfo oznaczył SVG ze skryptem jako zwykły image/svg+xml. Taki plik otwarty w przeglądarce to gotowy XSS, więc SVG wpuszczaj tylko świadomie.
Prawidłowy typ nie oznacza bezpiecznej zawartości. Prawdziwy obraz PNG z doklejonym na końcu kodem PHP przechodzi i finfo, i getimagesize(). W naszym kodzie jest nieszkodliwy, bo zapisujemy go z rozszerzeniem .png i wybieranym przez nas, ale tylko wtedy, gdy serwer nie wykonuje plików z tego katalogu jako PHP. Dodatkowa ochrona dla obrazów to ponowne zakodowanie: imagecreatefrompng() i imagepng() zapisują obraz od nowa, a doklejony ciąg <?php znika (sprawdzone na pliku testowym).
Uwaga mentora: najczęstszy błąd, jaki widać w kodzie początkujących, to zaufanie do $_FILES['type'] i do rozszerzenia z nazwy pliku. Obie wartości wysyła przeglądarka, więc plik shell.php podpisany jako image/png serwer uznaje za obrazek. Sprawdzaj zawartość przez finfo i zapisuj pod własną nazwą.
Dlaczego $_FILES i $_POST są puste w PHP?
To pytanie wraca na Stack Overflow od lat, a jedno z nich (jak wykryć błąd, gdy $_FILES jest puste) nadal nie ma żadnej odpowiedzi. Skoro tablica jest pusta, PHP nie ma jak podać kodu błędu, więc trzeba znać przyczyny:
| Przyczyna | Co widać | Jak rozpoznać |
|---|---|---|
żądanie większe niż post_max_size | $_FILES i $_POST puste, w logu „POST Content-Length of … exceeds the limit” | CONTENT_LENGTH większe od zera przy pustych tablicach |
brak enctype="multipart/form-data" | $_FILES puste, nazwa pliku jako zwykłe pole w $_POST | sprawdź formularz |
ręcznie ustawiony Content-Type w fetch | ostrzeżenie „Missing boundary in multipart/form-data POST data” | usuń nagłówek |
file_uploads = Off w php.ini | brak plików mimo poprawnego żądania | sprawdź ini_get('file_uploads') |
Ostrzeżenia o przekroczonym limicie pojawiają się w fazie startu żądania, zanim wykona się Twój kod, więc nie złapiesz ich w try/catch. Zobaczysz je w logu błędów PHP (na produkcji display_errors powinno być wyłączone, więc na ekranie ich nie będzie). W kodzie zostaje warunek, który wstawiliśmy do upload.php: niepusty CONTENT_LENGTH przy pustych $_POST i $_FILES. Ten warunek nie odróżni przekroczonego limitu od brakującego boundary, dlatego komunikat dla użytkownika brzmi „puste albo większe niż limit”, a prawdziwą przyczynę podaje log.
Jak zwiększyć limit wielkości pliku w PHP?
Trzeba ustawić dwa limity w php.ini, nie jeden:
upload_max_filesize = 10M
post_max_size = 12M
max_file_uploads = 20
| Dyrektywa | Co ogranicza | Domyślnie |
|---|---|---|
upload_max_filesize | rozmiar jednego pliku | 2M |
post_max_size | rozmiar całego żądania (wszystkie pliki i pola) | 8M |
max_file_uploads | liczbę plików w jednym żądaniu | 20 |
post_max_size musi być większe od upload_max_filesize, bo w żądaniu są jeszcze inne pola, a przy wielu plikach limit dotyczy ich sumy. Jeśli podniesiesz tylko upload_max_filesize do 10M, a post_max_size zostanie na 8M, plik 9 MB nadal nie przejdzie, a $_FILES będzie puste.
Gdzie to ustawić, zależy od środowiska. Ścieżkę używanego pliku pokaże php --ini lub phpinfo(), a po zmianie trzeba zrestartować PHP-FPM lub serwer WWW. Na hostingu współdzielonym często działa plik .user.ini w katalogu aplikacji. Sprawdzisz efekt bez zgadywania: ini_get('upload_max_filesize') zwraca wartość, którą PHP faktycznie używa.
Pamiętaj, że przed PHP stoi jeszcze serwer WWW. W nginx obowiązuje client_max_body_size (domyślnie 1 MB) i większe żądanie dostaje błąd 413 zanim dotrze do PHP. Dlatego przy błędzie 413 bez śladu w logach PHP zacznij właśnie od niej.
Dlaczego move_uploaded_file() zwraca false?
Funkcja zwraca false, gdy nie może przenieść pliku, a przyczynę podaje ostrzeżenie w logu. Najczęściej:
| Ostrzeżenie w logu | Przyczyna | Co zrobić |
|---|---|---|
| „Failed to open stream: Permission denied” | użytkownik, na którym działa PHP, nie ma prawa zapisu do katalogu | nadaj prawo zapisu właściwemu użytkownikowi (np. chown), a nie chmod 777 |
| „No such file or directory” | katalog docelowy nie istnieje albo ścieżka jest błędna | utwórz katalog, użyj __DIR__ zamiast ścieżek względnych |
| plik tymczasowy nie istnieje | upload się nie powiódł (error ≠ 0) lub plik już przeniesiono | sprawdź error przed przeniesieniem |
W teście katalog uploads z prawami 555 dał dokładnie pierwszy przypadek: move_uploaded_file() zwróciła false, a w logu pojawiło się „Failed to open stream: Permission denied”. Prawo zapisu dla wszystkich (777) nie jest rozwiązaniem, tylko drugim problemem: każdy proces na serwerze może wtedy zmieniać wgrane pliki.
Uwaga mentora: gdy uczeń mówi, że plik się nie wgrywa, sprawdzam zawsze trzy rzeczy w tej kolejności: czy formularz ma enctype="multipart/form-data", czy plik mieści się w post_max_size i czy katalog docelowy jest zapisywalny dla użytkownika PHP. Dopiero potem zaglądam do kodu.
Jakie uprawnienia (chmod) ustawić dla katalogu z uploadami?
Prawa w Linuksie ustawiasz osobno dla trzech grup: właściciela, grupy i pozostałych. Każda dostaje sumę: odczyt (4), zapis (2) i wykonanie (1). Katalog z uploadami potrzebuje zapisu tylko dla użytkownika, na którym działa PHP (nazwa zależy od serwera, na Debianie i Ubuntu to zwykle www-data). Pełne wyjaśnienie wartości, chown, umask i błędu „Permission denied” znajdziesz w tekście o chmod i uprawnieniach plików w Linuksie.
| Tryb | Dla czego | Zastosowanie |
|---|---|---|
755 (rwxr-xr-x) | katalog | właściciel pisze i wchodzi, reszta tylko czyta |
750 (rwxr-x---) | katalog | reszta nie ma dostępu wcale |
644 (rw-r--r--) | plik | właściciel pisze, reszta czyta |
777 | cokolwiek | nigdy: każdy proces na serwerze może zmieniać pliki |
Polecenia chown i chmod poprzedza sudo, które uruchamia komendę z prawami administratora (wyjaśniam je w tekście o chmod, do którego prowadzi link powyżej):
ls -l uploads
sudo chown www-data:www-data uploads
sudo chmod 750 uploads
Wgranym plikom nie nadawaj bitu wykonywania (chmod +x): obraz czy PDF nie jest programem. W teście plik zapisany przez move_uploaded_file() dostał 644 (przy umask 022, macOS), a katalog 755. Na serwerze wartości zależą od umask i konfiguracji, więc sprawdź je przez ls -l.
Jak przesłać wiele plików naraz w PHP?
W HTML dodajesz multiple i nazwę z nawiasami kwadratowymi: <input type="file" name="photos[]" multiple>. PHP układa wtedy $_FILES['photos'] w nietypowy sposób: klucze (name, tmp_name, error, size) są na wierzchu, a w każdym z nich leży tablica wartości dla kolejnych plików. Wygodniej przepisać to na listę plików, do której zastosujesz tę samą walidację co dla pojedynczego pliku:
/** Zamienia $_FILES['photos'] ze struktury „kolumnowej” na listę plików. */
function normalizeFiles(array $input): array
{
$files = [];
foreach ($input['name'] as $i => $name) {
$files[] = [
'name' => $name,
'type' => $input['type'][$i],
'tmp_name' => $input['tmp_name'][$i],
'error' => $input['error'][$i],
'size' => $input['size'][$i],
];
}
return $files;
}
foreach (normalizeFiles($_FILES['photos'] ?? ['name' => []]) as $file) {
// tu ta sama walidacja i zapis co dla jednego pliku
}
Uważaj na max_file_uploads. W teście przy limicie 2 i trzech wysłanych plikach trzeci został po cichu odrzucony: w $_FILES znalazły się dwa wpisy, a jedyną wskazówką było ostrzeżenie w logu. Jeśli akceptujesz wiele plików, porównaj liczbę wysłanych z liczbą otrzymanych. Każdy plik ma też własny error, więc jeden zbyt duży plik nie psuje reszty (w teście dostał error = 1, a drugi przeszedł).
Jak wysłać plik z JavaScriptu przez fetch?
Dla przeglądarki plik i pola opakowujesz w FormData i wysyłasz bez ręcznego ustawiania nagłówka Content-Type:
const form = new FormData();
form.append("title", "Moje zdjęcie");
form.append("avatar", fileInput.files[0]);
const response = await fetch("/upload.php", { method: "POST", body: form });
const result = await response.json();
Content-Type: multipart/form-data wymaga jeszcze parametru boundary, czyli ciągu rozdzielającego części, który przeglądarka generuje sama przy FormData. Jeśli ustawisz nagłówek ręcznie, boundary zniknie, a PHP odpowie ostrzeżeniem „Missing boundary in multipart/form-data POST data” i pustym $_FILES. Ten przykład (w wersji z Blob) sprawdzono w Node 22: poprawna wersja zwróciła 201, wersja z ręcznym nagłówkiem pusty $_FILES. Szczegóły samej obsługi plików w przeglądarce znajdziesz w tekście o uploadzie po stronie frontendu.
Gdzie przechowywać wgrane pliki na serwerze?
Zasady, które warto przyjąć od początku:
- Katalog poza publicznym katalogiem serwera. W przykładzie
uploadsleży obok katalogupublic, więc nikt nie otworzy pliku bezpośrednim adresem, a Ty serwujesz go własnym skryptem po sprawdzeniu uprawnień. - Brak wykonywania kodu w katalogu z uploadami. Jeśli musisz trzymać pliki w katalogu publicznym, wyłącz w nim wykonywanie PHP w konfiguracji serwera.
- W bazie tylko metadane. Zapisz nazwę nadaną przez siebie, oryginalną nazwę (do wyświetlenia), typ i rozmiar, a zapytania wykonuj przez przygotowane zapytania PDO, bo oryginalna nazwa pliku to dane od użytkownika jak każde inne.
- Własna nazwa pliku. Nazwa od klienta może zawierać ścieżki (
../), spacje, polskie znaki albo kolidować z istniejącym plikiem.
Ten sam wzorzec (walidacja po zawartości, własna nazwa, zapis poza katalogiem publicznym) obowiązuje w każdym języku. Jeśli porównujesz podejścia, zobacz upload plików w FastAPI.
Co zapamiętać o uploadzie plików w PHP?
- Formularz potrzebuje
method="post"ienctype="multipart/form-data". nameitypew$_FILESpochodzą od klienta, więc nie podejmuj na ich podstawie żadnych decyzji.- Zawsze sprawdzaj
error, rozmiar i typ po zawartości (finfo), a typy dozwolone trzymaj jako konkretną listę. - Zapisuj pod własną, losową nazwą w katalogu poza publicznym, przez
move_uploaded_file(). - Ustaw oba limity (
upload_max_filesizeipost_max_size) i pamiętaj o limicie serwera WWW. Pusty$_FILESto prawie zawsze limit albo brakenctype.
Jeśli chcesz zbudować na tej podstawie prawdziwą aplikację, z bazą, logowaniem i testami, ścieżka kurs PHP prowadzi przez to krok po kroku, razem z mentorem, który sprawdza Twój kod.


