Upload plików w PHP: $_FILES, walidacja i bezpieczny zapis

Mateusz Bogolubow

Mateusz Bogolubow

10-11-2026

Jak odebrać plik w PHP: formularz multipart, tablica $_FILES, kody błędów, kontrola typu i rozmiaru, limity w php.ini i bezpieczny zapis. Z kodem uruchomionym na PHP 8.4.

Upload plików w PHP: $_FILES, walidacja i bezpieczny zapis
Plik w PHP odbierasz z tablicy $_FILES, a formularz musi mieć method="post" i enctype="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 w php.ini (upload_max_filesize i post_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:

KluczCo zawieraCzy można mu ufać?
nameoryginalna nazwa pliku z komputera użytkownikaNie, wysyła ją klient
full_pathścieżka względna wysłana przez przeglądarkę (od PHP 8.1)Nie, wysyła ją klient
typetyp MIME zadeklarowany przez przeglądarkęNie, wysyła go klient
tmp_nameścieżka pliku tymczasowego na serwerzeTak, tworzy ją PHP
errorkod błędu (UPLOAD_ERR_OK = 0 oznacza sukces)Tak
sizerozmiar w bajtach, policzony przez PHPTak (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_OK sprawdzasz zawsze pierwsze. Przy błędzie tmp_name jest puste, a size ró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 finfo na podstawie zawartości. Dodatkowe getimagesize() zwraca false dla 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.png zapisał się jako zwykły <32 znaki>.png w katalogu uploads.

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łaWartośćZnaczenieTypowa przyczyna
UPLOAD_ERR_OK0brak błędu
UPLOAD_ERR_INI_SIZE1plik większy niż upload_max_filesizedomyślny limit to 2 MB
UPLOAD_ERR_FORM_SIZE2plik większy niż ukryte pole MAX_FILE_SIZE w formularzurzadko używany mechanizm
UPLOAD_ERR_PARTIAL3plik dotarł tylko częściowoprzerwane połączenie
UPLOAD_ERR_NO_FILE4nie wybrano plikupuste pole file
UPLOAD_ERR_NO_TMP_DIR6brak katalogu tymczasowegozła konfiguracja serwera
UPLOAD_ERR_CANT_WRITE7nie udało się zapisać pliku na dyskubrak miejsca lub uprawnień
UPLOAD_ERR_EXTENSION8zapis przerwało rozszerzenie PHPrzadkie, 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óbSkąd bierze informacjęWiarygodność
$_FILES['x']['type']nagłówek wysłany przez przeglądarkężadna
rozszerzenie z namenazwa wpisana przez użytkownikażadna
finfo (FILEINFO_MIME_TYPE)pierwsze bajty pliku na serwerzedobra, 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:

PlikTyp zwrócony przez finfo
PNGimage/png
PDFapplication/pdf
ZIPapplication/zip
DOCXapplication/vnd.openxmlformats-officedocument.wordprocessingml.document
CSV (mały plik testowy)text/csv
SVGimage/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:

PrzyczynaCo 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 $_POSTsprawdź formularz
ręcznie ustawiony Content-Type w fetchostrzeżenie „Missing boundary in multipart/form-data POST data”usuń nagłówek
file_uploads = Off w php.inibrak plików mimo poprawnego żądaniasprawdź 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
DyrektywaCo ograniczaDomyślnie
upload_max_filesizerozmiar jednego pliku2M
post_max_sizerozmiar całego żądania (wszystkie pliki i pola)8M
max_file_uploadsliczbę plików w jednym żądaniu20

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 loguPrzyczynaCo zrobić
„Failed to open stream: Permission denied”użytkownik, na którym działa PHP, nie ma prawa zapisu do katalogunadaj 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łędnautwórz katalog, użyj __DIR__ zamiast ścieżek względnych
plik tymczasowy nie istniejeupload się nie powiódł (error ≠ 0) lub plik już przeniesionosprawdź 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.

TrybDla czegoZastosowanie
755 (rwxr-xr-x)katalogwłaściciel pisze i wchodzi, reszta tylko czyta
750 (rwxr-x---)katalogreszta nie ma dostępu wcale
644 (rw-r--r--)plikwłaściciel pisze, reszta czyta
777cokolwieknigdy: 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 uploads leży obok katalogu public, 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?

  1. Formularz potrzebuje method="post" i enctype="multipart/form-data".
  2. name i type w $_FILES pochodzą od klienta, więc nie podejmuj na ich podstawie żadnych decyzji.
  3. Zawsze sprawdzaj error, rozmiar i typ po zawartości (finfo), a typy dozwolone trzymaj jako konkretną listę.
  4. Zapisuj pod własną, losową nazwą w katalogu poza publicznym, przez move_uploaded_file().
  5. Ustaw oba limity (upload_max_filesize i post_max_size) i pamiętaj o limicie serwera WWW. Pusty $_FILES to prawie zawsze limit albo brak enctype.

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.

Masz pytanie do artykułu? Chcesz zgłosić błąd?

Pytaj ogólnie o temat artykułu albo napisz, co poprawić. Jeśli chcesz omówić swój konkretny przypadek, umów się na rozmowę. Ze względów bezpieczeństwa nie przeglądamy ani nie publikujemy żadnych linków. Wiadomości z linkami nie są zapisywane. Limit: 2 pytania na godzinę.

Wyślemy na niego link do potwierdzenia pytania i odpowiedź.

Ostateczną decyzję o opublikowaniu pytania podejmuje administrator.

Konsultacja i warsztaty devmentor.pl

Masz pytania?

Partnerzy

Firmy, z którymi współpracujemy

To partnerzy odpowiedzialni za staże po naszych kursach. Organizują je dla najlepszych absolwentów.

Zobacz wszystkich partnerów →
  • VR Medical
  • Sophona
  • Robot Force