Kontekst: PHP dziś, między „legacy” a nowoczesnością
Dlaczego PHP nadal dominuje w webie
PHP ma fatalną legendę z czasów „skryptów na szybko”, ale liczby są proste: ogromna część sieci wciąż działa na PHP. Nie tylko stare fora czy blogi, ale także duże serwisy, systemy e‑commerce i aplikacje biznesowe. Powód jest pragmatyczny – PHP jest łatwo dostępne na hostingach, ma ogromny ekosystem, a nowoczesne wersje (7.4, 8.0+) oferują wydajność i funkcje porównywalne z popularnymi językami backendowymi.
Różnica polega nie na samym języku, ale na podejściu. Ten sam PHP może służyć do sklejenia kilku plików .php z HTML‑em albo do budowy uporządkowanej aplikacji z warstwami, testami i sensowną architekturą. O tym decyduje sposób pisania kodu, nie sam fakt użycia PHP.
Wiele firm, które dzisiaj budują produkty SaaS, nadal wybiera PHP – nie dlatego, że „tak się zawsze robiło”, ale dlatego, że da się nim realizować nowoczesny PHP w praktyce: API JSON, mikroserwisy, integracje z chmurą, kolejki, komunikację asynchroniczną.
„Skrypty z 2009” kontra nowoczesne aplikacje PHP
Stare projekty PHP kojarzą się z jednym plikiem index.php, w którym jest wszystko: połączenie z bazą, HTML, logika biznesowa i walidacja formularza. Do tego funkcje globalne, zmienne z $_GET i SQL pisany jako string z doklejonym $_POST['id']. To jest źródło chaosu, nie sam język.
Nowoczesna aplikacja PHP to zupełnie inna półka. W praktyce oznacza to m.in.:
- autoloader PSR‑4 z Composera, a nie
requirew każdym pliku, - framework (np. Symfony, Laravel) albo przynajmniej autorski „micro‑MVC” z warstwami,
- typowanie (parametry, wartości zwrotne, obiekty zamiast tablic‑worków),
- oddzielenie widoku (HTML/JSON) od logiki biznesowej i warstwy danych,
- testy jednostkowe i narzędzia statycznej analizy, które łapią błędy przed produkcją.
Takie środowisko sprzyja zarówno czytelności, jak i bezpieczeństwu aplikacji webowych PHP. Łatwiej dodać walidację, limity, polityki uprawnień, kiedy kod jest podzielony na odpowiedzialności, zamiast żyć w jednym, gigantycznym pliku.
Greenfield vs. legacy: dwa światy jednego języka
Nowy projekt (greenfield) można od razu ustawić sensownie: najnowsza stabilna wersja PHP, framework, CI, PSR‑y, testy. Większe wyzwanie to kilku‑ czy kilkunastoletnie aplikacje, których nie da się przepisać od zera. W takich systemach kod bywa pełen „skróców”: kopiuj‑wklej, brak walidacji, mieszanie SQL z HTML. A jednak to właśnie w nich pracują ważne dane klientów.
Kluczowe jest podejście „stopniowej modernizacji”. Nie ma sensu marzyć o idealnym kodzie; lepiej wprowadzać małe, ale konkretne zmiany: wydzielić serwis, zastąpić konkatenację SQL parametryzacją, dodać typy do nowych metod. Każdy nowy fragment pisać „po nowemu”, a stare dotykać tylko przy okazji zmian biznesowych. Taka ewolucja pozwala mieć czytelny kod w zespołach bez paraliżowania projektu.
Czytelność i bezpieczeństwo jako jedno zagadnienie
Czytelność i bezpieczeństwo rzadko wychodzą dobrze, jeśli traktuje się je oddzielnie. Nieczytelny kod utrudnia zauważenie luk – np. brak walidacji danych wejściowych może być schowany w gąszczu if‑ów i duplikacji. Z drugiej strony, „dokręcanie” bezpieczeństwa na końcu projektu zwykle kończy się łagodnymi obejściami, które łatwo zepsuć.
W praktyce najbardziej opłaca się projektować bezpieczeństwo razem ze strukturą i stylem kodu. Gdy każda warstwa ma jasno określoną odpowiedzialność, łatwo wskazać miejsce na walidację, autoryzację czy ochronę przed XSS. Gdy kod jest spójny stylistycznie, zespół szybciej zauważa odchylenia: „dlaczego w tym miejscu nagle konkatenacja SQL zamiast prepared statement?”.

Fundamenty: środowisko, wersja PHP i narzędzia, bez których nie ma „nowocześnie”
Wersja PHP i ryzyka pracy na przestarzałych wydaniach
Nowe projekty powinny startować co najmniej z PHP 8.1+, a jeśli hosting i zależności pozwalają – z najnowszej stabilnej gałęzi. Każda starsza wersja to mniej funkcji bezpieczeństwa, gorsza wydajność i brak poprawek krytycznych błędów. Stare wersje (np. 5.x, wczesne 7.x) nie są wspierane bezpieczeństwem – korzystanie z nich w środowisku produkcyjnym to realne ryzyko.
W istniejących projektach warto ustalić plan migracji. Nawet jeśli nie da się od razu przejść z 5.6 na 8.2, można skoczyć na 7.4, usunąć oczywiste deprecacje, potem kolejny krok. Każdy upgrade to okazja, by dodać typy, wyrzucić przestarzałe biblioteki i zbliżyć się do nowoczesnego PHP w praktyce.
Composer i autoloading jako podstawa organizacji projektu
Composer rozwiązuje w PHP dwa duże problemy: zarządzanie zależnościami oraz ładowanie klas. Bez niego kod zamienia się w sieć ręcznych require i skryptów wrzucanych do katalogu lib/. Przy większym projekcie szybko traci się kontrolę.
Praktyczny minimalny setup:
- w katalogu projektu:
composer init, - skonfigurowany autoloader PSR‑4 (w
composer.json):"App": "src/", - w kodzie: konsekwentne użycie przestrzeni nazw i
src/jako głównego katalogu źródeł, - brak ręcznych
requirew logice biznesowej – tylkovendor/autoload.phpw wejściu aplikacji (front controller).
Takie podejście sprawia, że struktura kodu staje się przewidywalna: nazwa klasy jednoznacznie wskazuje plik, a IDE oraz narzędzia statyczne mają pełną wiedzę o projekcie.
Podstawowe narzędzia: linter, static analysis, formatter, debugger
Bez automatycznych narzędzi utrzymanie jakości przy wielu commitach dziennie jest nierealne. Minimalny zestaw na start:
- linter: wbudowane
php -llub PHPCS – łapie błędy składniowe, - analiza statyczna: PHPStan lub Psalm – wykrywa niespójne typy, martwy kod, brakujące metody,
- formatter: PHP-CS-Fixer lub PHP_CodeSniffer z regułami PSR‑12 – dba o jeden, spójny styl,
- debugger: Xdebug – pozwala zatrzymać wykonanie, obejrzeć zmienne, profilować.
Sam linter to za mało. Static analysis w połączeniu z typami pozwala usunąć całą klasę błędów jeszcze przed odpaleniem testów. Formatter wyrównuje styl, dzięki czemu code review skupia się na logice i bezpieczeństwie, a nie na przecinkach i spacji.
Środowiska dev/stage/prod i konfiguracja PHP
Jedno php.ini dla wszystkich środowisk to proszenie się o kłopoty. Środowisko deweloperskie powinno mieć:
display_errors = On,- obszerny log błędów,
- niższe poziomy cache’owania,
- debuggera (Xdebug) włączonego lokalnie.
Produkcyjne – odwrotnie:
display_errors = Off,- logowanie błędów do pliku/systemu logów,
- wyższe limity bezpieczeństwa (np.
disable_functions, kontrola uploadu), - wyłączony Xdebug.
Środowisko staging/test powinno jak najbardziej przypominać produkcję pod względem konfiguracji i backendów (baza, kolejki), ale bez prawdziwych danych użytkowników. Daje to realny obraz zachowania aplikacji przed wdrożeniem.
Mikro‑checklista dobrego startu projektu
Krótka lista kontrolna, którą warto przejść przy starcie lub przy modernizacji:
- Wersja PHP: min. 8.0, lepiej 8.1+; wsparcie bezpieczeństwa jest aktualne.
- Composer skonfigurowany, autoloader PSR‑4 działa, brak ręcznych
requirew logice. - Linter i analiza statyczna odpalane lokalnie i w CI (np. PHPStan na poziomie min. 3–5).
- Formatter (PHP-CS-Fixer) skonfigurowany zgodnie z PSR‑12.
- Oddzielne konfiguracje
php.inilub zmienne środowiskowe dla dev/stage/prod. - Logowanie błędów do osobnego kanału, nie wyświetlanie ich użytkownikowi.
Czytelny kod: zasady, które naprawdę da się stosować w codziennej pracy
Małe, wyspecjalizowane funkcje i klasy
Pliki po 2000 linii i klasy, które „robią wszystko” (god object), są naturalnym wrogiem czytelności. Nowoczesna aplikacja PHP korzysta z zasady: jedna klasa – jedna odpowiedzialność biznesowa lub techniczna. Nie musi to być akademickie „czyste” SOLID, ale po prostu zdrowy umiar.
Praktyczny wzorzec: zamiast jednego kontrolera, który obsługuje 10 różnych akcji formularza, każdy przypadek biznesowy ma swój handler lub metodę serwisową. Dzięki temu łatwo dołożyć walidację, logowanie zdarzeń, obsługę wyjątków – a w testach odpalasz pojedynczy wycinek logiki, a nie cały świat.
Jeśli chcesz pójść krok dalej, pomocny może być też wpis: Etyka w automatycznym sądownictwie – czy algorytm może sądzić?.
Jasne nazwy mówiące „co”, a nie „jak”
Nazwa metody process() nic nie mówi. createUserFromRegistrationForm() – już tak. Kod czyta się znacznie częściej niż pisze, dlatego nazwy powinny być maksymalnie opisowe. Szczególnie w zespołach, gdzie programiści wchodzą do projektu w różnych momentach.
Dobra praktyka to ukrywanie szczegółów implementacyjnych za sensownymi interfejsami. Zamiast:
$userId = $db->query("INSERT INTO users ...");
lepiej:
$userId = $this->userRepository->add($user);
Interfejs UserRepository jasno opisuje zamiar, a szczegóły SQL można zmieniać bez kompromitowania reszty kodu.
Typowanie parametrów i wartości zwrotnych
PHP 8 bez typów to marnowanie potencjału. Dodanie typów skalarów, obiektów i zwracanych wartości drastycznie ogranicza liczbę klas błędów: niepoprawne użycia metod, mylenie formatów danych, nieprzewidywalne null. Do tego narzędzia statycznej analizy mają o co się zaczepić.
Minimalne standardy, które realnie da się utrzymać:
- typy parametrów i zwracanych wartości w nowych metodach publicznych,
- stopniowe dodawanie typów do istniejących klas, które aktualnie modyfikujesz,
- wyłączenie trybu „mixed na wszystko” w analizie statycznej.
Przykład prostej metody serwisu z typami:
public function registerUser(string $email, string $plainPassword): int
{
// ...
return $userId;
}
Unikanie magicznych liczb i stringów
„Magiczne” wartości w kodzie utrudniają jego zrozumienie i sprzyjają błędom. Zamiast:
if ($status === 3) {
// ...
}
lepiej:
private const STATUS_ACTIVE = 3;
if ($status === self::STATUS_ACTIVE) {
// ...
}
W PHP 8.1 można użyć enum, co jeszcze bardziej porządkuje domenę:
enum UserStatus: string
{
case ACTIVE = 'active';
case BLOCKED = 'blocked';
}Dzięki temu łatwiej jest powiązać logikę z pojęciami biznesowymi, a nie z anonimowymi liczbami. Kod staje się też odporniejszy na literówki w stringach.
Refaktoryzacja HTML+PHP do serwisu i widoku
Częsty przypadek w starszych projektach: w jednym pliku jest pobieranie danych, logika, walidacja i HTML. Nawet mały krok w stronę rozdzielenia warstw sporo porządkuje. Przykład uproszczony:
// Stary kod (w jednym pliku)
if ($_POST) {
$email = $_POST['email'];
// walidacja, zapis do DB...
}
?>
<form method="post">
<input type="email" name="email">
</form>
Prostsza, bardziej czytelna alternatywa:
Rozdzielenie logiki kontrolera od warstwy prezentacji
// controller/RegisterController.php
public function register(Request $request): Response
{
$formData = $request->request->all();
if ($request->isMethod('POST')) {
$result = $this->registrationService->register($formData);
if ($result->isSuccess()) {
return $this->redirect('/thank-you');
}
$errors = $result->getErrors();
}
return $this->render('register.html.php', [
'errors' => $errors ?? [],
'old' => $formData ?? [],
]);
}
<!-- templates/register.html.php -->
<form method="post">
<label>Email
<input type="email" name="email"
value="<?= htmlspecialchars($old['email'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
</label>
<?php if (!empty($errors['email'])): ?>
<div class="error"><?= htmlspecialchars($errors['email'], ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<button type="submit">Zarejestruj</button>
</form>
HTML dostaje gotowe dane i błędy, a reguły biznesowe siedzą w serwisie. W mniejszych projektach wystarczy prosty „view helper” albo funkcja render(), nie trzeba od razu wchodzić w pełne silniki szablonów.
Obsługa błędów i wyjątków zamiast kodów zwrotnych
Kody zwrotne typu return false rozlewają się po kodzie i sprzyjają pominiętym warunkom. W PHP 8 dojrzałe użycie wyjątków mocno poprawia czytelność – błąd jest jasno nazwany, a miejsce jego obsługi zlokalizowane.
public function getUser(int $id): User
{
$user = $this->userRepository->find($id);
if (!$user) {
throw new UserNotFoundException("User #{$id} not found");
}
return $user;
}
W kontrolerze:
try {
$user = $this->userService->getUser($id);
} catch (UserNotFoundException $e) {
return $this->render('404.html.php', [], 404);
}
Dzięki temu nie powstają „if-y na wszelki wypadek” w każdej metodzie. Wyjątki techniczne (np. błąd bazy) łapie globalny handler i mapuje je na bezpieczną odpowiedź HTTP.
Proste kontrakty: interfejsy zamiast twardych zależności
Bezpośrednie używanie klas zewnętrznych (np. klienta HTTP, biblioteki cache) w logice domenowej szybko mści się przy testach i migracjach. Interfejsy w PHP to tania abstrakcja, która często wystarcza.
interface Mailer
{
public function send(string $to, string $subject, string $body): void;
}
final class SmtpMailer implements Mailer
{
public function __construct(private SmtpClient $client) {}
public function send(string $to, string $subject, string $body): void
{
$this->client->send($to, $subject, $body);
}
}
Serwis biznesowy działa na Mailer, nie na SmtpClient. Zamiana SMTP na API zewnętrzne to podmiana implementacji w kontenerze DI, a nie prucie połowy aplikacji.
Unikanie globalnego stanu i singletonów
Globalne zmienne, static::getInstance(), ukryte zależności w superglobalach – to wszystko utrudnia śledzenie przepływu danych. Nowoczesne PHP idzie w stronę jawnego wstrzykiwania zależności (constructor injection).
final class OrderService
{
public function __construct(
private OrderRepository $orders,
private PaymentGateway $payments,
) {}
public function pay(int $orderId): void
{
$order = $this->orders->get($orderId);
$this->payments->charge($order->getTotal());
$order->markAsPaid();
$this->orders->save($order);
}
}
Brak ukrytego GlobalConfig::get(), brak pojedynczego „magicznego” rejestru. Kod testuje się i czyta znacznie łatwiej.

Struktura aplikacji: od „spaghetti” do rozdzielonych warstw
Warstwy: prezentacja, logika biznesowa, dostęp do danych
Minimalny podział, który opłaca się nawet w średnich projektach:
W tym miejscu przyda się jeszcze jeden praktyczny punkt odniesienia: Jak kultura programistyczna wpływa na wizerunek firmy.
- warstwa prezentacji – kontrolery, API, CLI; przyjmują żądanie, zwracają odpowiedź,
- warstwa aplikacyjna/biznesowa – serwisy, use-case’y, reguły biznesowe,
- warstwa dostępu do danych – repozytoria, ORM, wywołania do zewnętrznych API.
W praktyce oznacza to, że w kontrolerze nie ma SQL ani cURL, a w repozytorium nie ma $_POST ani logiki walidacji formularza. Granice są proste, ale konsekwentne.
Przykładowa struktura katalogów
Dla typowej aplikacji HTTP prosta, zrozumiała struktura może wyglądać tak:
.
├─ public/
│ └─ index.php
├─ src/
│ ├─ Controller/
│ ├─ Domain/
│ │ ├─ Model/
│ │ └─ Service/
│ ├─ Infrastructure/
│ │ ├─ Persistence/
│ │ └─ Http/
│ └─ Application/
├─ templates/
├─ tests/
└─ config/
Controller/– wejście HTTP, mapowanie routingu na akcje,Domain/Model– encje, agregaty, typy domenowe,Domain/Service– kluczowe przypadki użycia i reguły,Infrastructure/– konkretne adaptery do bazy, cache, zewnętrznych usług,Application/– ewentualne use-case’y lub fasady dla warstwy prezentacji.
To tylko szablon. Kluczowe jest to, by nie mieszać warstw w jednym katalogu ani w jednej klasie.
Front controller i routing zamiast dziesiątek „.php” w public/
Stary styl: każdy adres URL wskazuje na osobny skrypt .php. Modernizacja zaczyna się od jednego front controllera i routingu.
<?php
// public/index.php
require __DIR__ . '/../vendor/autoload.php';
$kernel = new AppKernel();
$response = $kernel->handle(Request::createFromGlobals());
$response->send();
Resztą zajmuje się router i kontener DI. Dzięki temu można centralnie wpiąć middleware (logowanie, auth, rate limiting) i globalną obsługę wyjątków.
Wstrzykiwanie konfiguracji przez ENV zamiast stałych w kodzie
Stałe typu DB_HOST = 'localhost' wpisane w klasę to szybka droga do wycieków i problemów z różnymi środowiskami. Konfiguracja w nowoczesnych aplikacjach zwykle pochodzi ze zmiennych środowiskowych.
$dbDsn = sprintf(
'mysql:host=%s;dbname=%s;charset=utf8mb4',
$_ENV['DB_HOST'],
$_ENV['DB_NAME'],
);
$pdo = new PDO($dbDsn, $_ENV['DB_USER'], $_ENV['DB_PASS']);
W projektach z frameworkiem konfiguracja ENV przechodzi często przez warstwę pośrednią (np. config/*.php), ale źródło pozostaje to samo: środowisko, nie twardy kod.
Granice modułów i mikroserwisy „na później”
Zanim pojawi się pokusa rozdzielania aplikacji na mikroserwisy, zwykle wystarczy klarowny podział modułów w monolicie. Przykład: moduł „Faktury”, „Użytkownicy”, „Raporty” – każdy ma własne modele, serwisy, repozytoria, ale działają w jednym procesie PHP i jednej bazie.
Dopiero gdy moduł staje się realnym osobnym produktem (osobny zespół, osobna skala), warto myśleć o wydzieleniu go poza kod. Do tego momentu sensowniej utrzymać jedną aplikację, lecz o jasnych granicach w strukturze katalogów i przestrzeniach nazw.
Standardy kodowania i automatyczne „pilnowanie porządku”
PSR jako bazowa umowa w zespole
PHP-FIG zdefiniowało kilka praktycznych standardów, które dobrze przyjąć jako punkt wyjścia:
- PSR-1 – podstawowe standardy kodowania,
- PSR-12 – rozszerzony styl kodu (następca PSR-2),
- PSR-4 – autoloading klas,
- PSR-3 – interfejs logowania,
- PSR-7/15/17 – HTTP messages i middleware (szczególnie przydatne poza frameworkami).
Nie trzeba implementować wszystkiego na raz. PSR-12 + PSR-4 + PSR-3 w zupełności wystarczą, żeby kilka projektów różnych osób wyglądało spójnie i korzystało z kompatybilnych bibliotek.
Konfiguracja PHP-CS-Fixer albo PHPCS w projekcie
Formatter ma sens wtedy, gdy jest częścią workflow, a nie jednorazową akcją. Typowy .php-cs-fixer.dist.php może wyglądać tak:
<?php
$finder = PhpCsFixerFinder::create()
->in([__DIR__ . '/src', __DIR__ . '/tests']);
return (new PhpCsFixerConfig())
->setRiskyAllowed(true)
->setRules([
'@PSR12' => true,
'strict_param' => true,
'array_syntax' => ['syntax' => 'short'],
])
->setFinder($finder);
Następnie w composer.json:
{
"scripts": {
"cs-fix": "php-cs-fixer fix"
}
}
Jeden zespół, jeden config, automatyczny styl. Review nie musi się toczyć o nawiasy.
Pre-commit hook i CI jako strażnik jakości
Ręczne odpalanie narzędzi szybko znika w natłoku zadań. Pomaga prosty hook Gita oraz konfiguracja w CI.
#!/bin/sh
# .git/hooks/pre-commit
vendor/bin/php-cs-fixer fix --quiet
vendor/bin/phpstan analyse --level=max src tests
W CI (np. GitHub Actions) osobne kroki: kompozytor, static analysis, testy jednostkowe. Commit, który nie przechodzi static analysis, w ogóle nie ląduje w głównej gałęzi.
Logowanie zgodne z PSR-3
Zamiast pisać własny system logowania, opłaca się użyć PsrLogLoggerInterface i konkretnej implementacji (np. Monolog). Dzięki temu:
- podmiana backendu logów (plik, syslog, ELK, Sentry) jest prosta,
- biblioteki zewnętrzne korzystające z PSR-3 integrują się bez tarcia,
- poziomy logów (
info,warning,error) są ustandaryzowane.
final class RegistrationService
{
public function __construct(
private UserRepository $users,
private LoggerInterface $logger,
) {}
public function register(array $data): RegistrationResult
{
// ...
$this->logger->info('User registered', ['email' => $data['email']]);
// ...
}
}

Dane wejściowe: filtrowanie, walidacja i zasada „nie ufaj niczemu”
Oddzielenie filtrowania od walidacji
Rozsądny model przetwarzania danych wejściowych ma dwa kroki:
- filtrowanie – konwersja typów, obcięcie białych znaków, normalizacja,
- walidacja – sprawdzenie reguł biznesowych (zakresy, wymagane pola, unikalność).
Filtrowanie można realizować prostymi funkcjami albo gotowymi komponentami formularzy/DTO. Ważne, by nie mieszać tego w losowych miejscach kodu.
Bezpieczne pobieranie danych z superglobali
Bezpośrednie używanie $_GET, $_POST, $_COOKIE w logice biznesowej prowadzi do kodu trudnego do testów i utrzymania. Lepszym podejściem jest wprowadzenie obiektu żądania (nawet bardzo prostego), który kapsułkuje źródła danych.
final class Request
{
public function __construct(
private array $query,
private array $request,
private array $cookies,
private array $server,
) {}
public static function fromGlobals(): self
{
return new self($_GET, $_POST, $_COOKIE, $_SERVER);
}
public function getString(string $key, ?string $default = null): ?string
{
$value = $this->request[$key] ?? $this->query[$key] ?? $default;
return is_string($value) ? trim($value) : $default;
}
}
Logika biznesowa dostaje już gotowe wartości, a nie surowe dane z superglobali.
Walidacja z jasnym modelem błędów
Chaotyczne tablice z błędami typu $errors['field'][] = '...' szybko wymykają się spod kontroli. Pomaga prosty model wyników walidacji lub użycie zewnętrznej biblioteki.
Model walidacji, który da się sensownie testować
Zamiast luźnych tablic lepiej zdefiniować prostą klasę opisującą wynik walidacji. Dzięki temu serwis może dostać ValidInput lub jasno odrzucony wynik.
final class ValidationError
{
public function __construct(
public string $field,
public string $message,
public ?string $code = null,
) {}
}
final class ValidationResult
{
/** @var ValidationError[] */
private array $errors = [];
public function addError(string $field, string $message, ?string $code = null): void
{
$this->errors[] = new ValidationError($field, $message, $code);
}
public function isValid(): bool
{
return $this->errors === [];
}
/** @return ValidationError[] */
public function getErrors(): array
{
return $this->errors;
}
}
Walidator zwraca ValidationResult, a kontroler lub handler decyduje, jak pokazać błędy użytkownikowi.
final class RegistrationValidator
{
public function validate(array $input): ValidationResult
{
$result = new ValidationResult();
if (empty($input['email'])) {
$result->addError('email', 'Email jest wymagany', 'required');
} elseif (!filter_var($input['email'], FILTER_VALIDATE_EMAIL)) {
$result->addError('email', 'Nieprawidłowy adres email', 'invalid');
}
if (empty($input['password']) || mb_strlen($input['password']) < 8) {
$result->addError('password', 'Hasło musi mieć min. 8 znaków', 'too_short');
}
return $result;
}
}
DTO / command jako kontrakt między warstwami
Żeby logika biznesowa nie operowała na surowych tablicach, pomocne są proste obiekty wejściowe (DTO/command). Tworzymy je wyłącznie z przefiltrowanych i zwalidowanych danych.
final class RegisterUserCommand
{
public function __construct(
public string $email,
public string $password,
public ?string $name,
) {}
}
Kontroler wykonuje konwersję danych HTTP → DTO. Dalej pracujemy już na stabilnym typie.
final class RegistrationController
{
public function __construct(
private RegistrationValidator $validator,
private RegistrationService $service
) {}
public function __invoke(Request $request): Response
{
$raw = [
'email' => $request->getString('email'),
'password' => $request->getString('password'),
'name' => $request->getString('name'),
];
$validation = $this->validator->validate($raw);
if (! $validation->isValid()) {
return new JsonResponse([
'status' => 'error',
'errors' => $validation->getErrors(),
], 422);
}
$command = new RegisterUserCommand(
$raw['email'],
$raw['password'],
$raw['name'],
);
$userId = $this->service->register($command);
return new JsonResponse(['status' => 'ok', 'id' => $userId], 201);
}
}
Testy jednostkowe walidatora i serwisu nie wymagają wtedy superglobali ani fałszywych serwerów HTTP.
Filtrowanie i walidacja a XSS
Niebezpieczne jest zarówno brak filtrowania, jak i „filtrowanie na ślepo”. HTML i JavaScript powinny być kodowane przy wyjściu, a nie „czyszczone” przy wejściu.
- Przy wejściu – normalizacja (trim, typy, maksymalna długość, whitelist znaków tam, gdzie ma to sens),
- Przy wyjściu – właściwy
escapezależny od kontekstu: HTML, atrybut, JavaScript, URL.
W szablonach Twig/Blade domyślny escape HTML rozwiązuje większość problemów. Ręcznie, w czystym PHP, nie należy wstawiać surowych wartości do HTML bez htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').
Bezpieczna obsługa uploadu plików
Pliki to jedno z prostszych źródeł kłopotów. Minimalne zabezpieczenia:
- limit rozmiaru (PHP
upload_max_filesize, własna kontrola w kodzie), - sprawdzanie MIME/rozszerzenia na whitelist, nie blacklist,
- zapisywanie poza
public/i podawanie adresów przez kontroler (streaming), - zmiana nazw plików na losowe identyfikatory (bez
$_FILES['name']wprost).
$file = $_FILES['avatar'] ?? null;
if (! $file || $file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('Upload nieudany');
}
$allowedMime = ['image/jpeg', 'image/png'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, $allowedMime, true)) {
throw new RuntimeException('Niedozwolony typ pliku');
}
$targetDir = __DIR__ . '/../var/uploads';
$filename = bin2hex(random_bytes(16)) . image_type_to_extension(exif_imagetype($file['tmp_name']));
move_uploaded_file($file['tmp_name'], $targetDir . '/' . $filename);
Bezpieczeństwo na poziomie bazy: SQL Injection, ORM i dostęp do danych
Parametryzowane zapytania jako domyślny mechanizm
Bez względu na to, czy używasz ORM, czy „gołego” PDO, wszystkie dane użytkownika muszą trafiać do SQL jako parametry, nie jako konkatenowane stringi.
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
Unikaj konstruowania WHERE/ORDER BY na podstawie surowych danych. Dla nazw kolumn i kierunku sortowania stosuj jawne mapowania.
$allowedSort = ['created_at' => 'created_at', 'email' => 'email'];
$allowedDir = ['asc' => 'ASC', 'desc' => 'DESC'];
$sort = $allowedSort[$input['sort'] ?? 'created_at'] ?? 'created_at';
$dir = $allowedDir[strtolower($input['dir'] ?? 'desc')] ?? 'DESC';
$sql = sprintf('SELECT * FROM users ORDER BY %s %s LIMIT :limit OFFSET :offset', $sort, $dir);
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':limit', (int) $input['limit'], PDO::PARAM_INT);
$stmt->bindValue(':offset', (int) $input['offset'], PDO::PARAM_INT);
$stmt->execute();
Repository zamiast SQL w kontrolerach
SQL rozsiane po kontrolerach kończy się duplikacją i ryzykiem błędów. Prostszy i czytelniejszy jest wzorzec repository.
interface UserRepository
{
public function findByEmail(string $email): ?User;
public function add(User $user): void;
}
final class PdoUserRepository implements UserRepository
{
public function __construct(private PDO $pdo) {}
public function findByEmail(string $email): ?User
{
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (! $row) {
return null;
}
return new User(
id: (int) $row['id'],
email: $row['email'],
passwordHash: $row['password_hash'],
);
}
public function add(User $user): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO users (email, password_hash) VALUES (:email, :password_hash)'
);
$stmt->execute([
'email' => $user->email,
'password_hash' => $user->passwordHash,
]);
}
}
Kontroler nie „zna” SQL, wywołuje metody repozytorium. Przy migracji na ORM trzeba podmienić tylko implementację.
Używanie ORM z głową
ORM (Doctrine, Eloquent) upraszczają CRUD, ale łatwo wygenerować nimi n+1 i ciężkie zapytania. Kilka prostych zasad:
- profiluj zapytania (np. toolbar w Symfony/Laravel, logi SQL),
- jawnie używaj
JOIN FETCH/with(), gdy ładujesz powiązane encje, - dla ciężkich raportów rozważ czyste SQL lub read-model w osobnym repozytorium,
- nie mieszaj logiki biznesowej z kodem specyficznym dla ORM w samej encji.
Przykład rozdzielenia: encja biznesowa + adapter Doctrine, który ją mapuje.
final class User
{
public function __construct(
public int $id,
public string $email,
public string $passwordHash,
) {}
}
/**
* @Entity
* @Table(name="users")
*/
class UserEntity
{
/** @Id @Column(type="integer") @GeneratedValue */
public int $id;
/** @Column(type="string", unique=true) */
public string $email;
/** @Column(type="string", name="password_hash") */
public string $passwordHash;
}
Mapowanie między User a UserEntity odbywa się w repozytorium Doctrine. Logika domenowa nie wie, czy stoi za tym Doctrine, PDO, czy inna baza.
Transakcje i spójność danych
Aktualizacje w kilku tabelach wymagają transakcji. Bez nich aplikacja będzie produkować „pół zapisaną” rzeczywistość przy każdym błędzie w połowie procesu.
final class OrderService
{
public function __construct(
private PDO $pdo,
private OrderRepository $orders,
private StockService $stock,
) {}
public function placeOrder(PlaceOrderCommand $command): int
{
try {
$this->pdo->beginTransaction();
$order = Order::createFromCommand($command);
$this->orders->add($order);
$this->stock->reserveForOrder($order);
$this->pdo->commit();
return $order->id;
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
}
Przy ORM transakcje obsługuje zazwyczaj EntityManager, ale wzorzec jest identyczny: otwarcie, zestaw operacji, commit albo rollback przy wyjątku.
Ograniczanie uprawnień w bazie
Konto bazy danych używane przez aplikację nie musi mieć uprawnień do DROP TABLE ani tworzenia nowych schematów. Wystarcza zwykle:
- odczyt/zapis danych produkcyjnych,
- brak dostępu do systemowych tabel administracyjnych,
- osobne konta dla odczytu (np. raporty) i zapisu.
W razie SQL Injection szkody są wtedy ograniczone zakresem roli w bazie, a nie nieograniczone.
Przechowywanie haseł i danych wrażliwych
Hasła użytkowników nigdy nie powinny być przechowywane wprost ani w postaci SHA1/MD5. PHP ma wbudowane funkcje do bezpiecznego haszowania.
$hash = password_hash($plainPassword, PASSWORD_DEFAULT);
// Weryfikacja
if (! password_verify($inputPassword, $hash)) {
throw new AuthenticationException();
}
Dane naprawdę wrażliwe (PESEL, numery dokumentów, tokeny dostępu) można dodatkowo szyfrować po stronie aplikacji przed zapisaniem do bazy (np. libsodium). Klucze trzymane są w bezpiecznym magazynie (ENV + KMS, nie w repozytorium).
Paginate i limity zamiast „SELECT * FROM …” bez opamiętania
Brak stronicowania to nie tylko problem wydajności, ale także bezpieczeństwa: łatwo wygenerować zapytanie obciążające bazę i inne usługi. API i interfejsy powinny stosować limity.
Firmy budujące dojrzałą kulturę techniczną traktują te aspekty łącznie. Na portalach typu praktyczne wskazówki: technologia często przewija się wątek, że czytelny, bezpieczny kod wpływa nie tylko na komfort pracy programistów, ale też na zaufanie klientów do produktu.
$page = max(1, (int) ($request->getString('page') ?? 1));
$perPage = min(100, max(1, (int) ($request->getString('per_page') ?? 20)));
$offset = ($page - 1) * $perPage;
$stmt = $pdo->prepare(
'SELECT * FROM users ORDER BY id DESC LIMIT :limit OFFSET :offset'
);
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
Górny limit chroni przed tym, żeby ktoś nie ściągał w jednym żądaniu całej tabeli tylko dlatego, że potrafi.
Najczęściej zadawane pytania (FAQ)
Jak pisać nowoczesny, czytelny kod w PHP, a nie „skrypty z 2009 roku”?
Podstawowa zmiana to odejście od jednego pliku index.php, w którym jest wszystko, w stronę podziału na warstwy: kontrolery, logikę biznesową (serwisy), repozytoria dostępu do danych oraz osobno widoki (HTML/JSON). Każda warstwa ma jasno określoną odpowiedzialność i nie miesza się np. SQL z HTML.
W praktyce oznacza to wykorzystanie Composera z autoloaderem PSR‑4, przestrzeni nazw, klas zamiast zbioru funkcji globalnych i typowania parametrów oraz wartości zwrotnych. Nowy kod pisz od razu „po nowemu”, a stare fragmenty porządkuj przy każdej zmianie biznesowej, zamiast próbować przepisać całość na raz.
Jak połączyć czytelność kodu PHP z bezpieczeństwem aplikacji?
Bezpieczeństwo dużo łatwiej ogarnąć, gdy kod jest uporządkowany. Jeśli walidacja danych wejściowych, autoryzacja czy filtrowanie pod XSS są w konkretnych miejscach (np. warstwa request/DTO, middleware, serwis autoryzacji), nie trzeba ich szukać w gąszczu instrukcji warunkowych i duplikacji.
Dobrym nawykiem jest trzymanie się stałych punktów: walidacja jak najbliżej wejścia (kontroler/handler), autoryzacja przed logiką biznesową, dostęp do bazy wyłącznie przez przygotowane zapytania (prepared statements), a generowanie HTML z escapowaniem w warstwie widoku. Przy spójnym stylu od razu widać odstępstwa, np. nagłą konkatenację SQL.
Jaką wersję PHP wybrać do nowych i istniejących projektów?
Nowe projekty warto startować co najmniej na PHP 8.1+, a jeśli hosting i biblioteki pozwalają – na najnowszej stabilnej wersji. Dostajesz wtedy nie tylko lepszą wydajność, ale też aktualne łatki bezpieczeństwa i nowoczesne funkcje języka, które ułatwiają pisanie czytelnego kodu (typy, enumy, atrybuty).
Przy starszych aplikacjach podejście etapowe działa najlepiej: najpierw przeskok na wersję, która nadal ma wsparcie bezpieczeństwa (np. 7.4 lub 8.0), usunięcie deprecacji, dopiero potem kolejny upgrade. Każdy krok traktuj jako okazję do: dodania typów, wymiany przestarzałych bibliotek i uporządkowania struktury katalogów.
Po co mi Composer i autoloading w projekcie PHP?
Bez Composera większy projekt szybko zamienia się w sieć ręcznych require i plików wrzucanych do lib/. Composer rozwiązuje dwa problemy naraz: zarządza zewnętrznymi bibliotekami oraz generuje autoloader, który w przewidywalny sposób ładuje klasy na podstawie ich przestrzeni nazw.
Minimalny, sensowny setup to: composer init w katalogu projektu, konfiguracja autoloadera PSR‑4 (np. "App": "src/") i trzymanie całego kodu aplikacji w src/. W logice biznesowej nie używasz require, a jedynie dołączasz vendor/autoload.php w wejściu aplikacji (np. front controller). IDE i narzędzia analizy statycznej wtedy „rozumieją” projekt.
Jakie narzędzia do PHP pomagają pisać bezpieczny i czysty kod?
Podstawowy zestaw, który realnie podnosi jakość, to:
- linter (np.
php -llub PHPCS) – wyłapuje błędy składniowe, - analiza statyczna (PHPStan, Psalm) – wskazuje problemy z typami, martwy kod, brakujące metody,
- formatter (PHP-CS-Fixer, PHPCS z PSR‑12) – pilnuje jednolitego stylu,
- debugger (Xdebug) – pozwala zatrzymać wykonanie, obejrzeć zmienne, profilować.
Te narzędzia odpalaj lokalnie i w CI. W praktyce efektem jest mniej „głupich” błędów na produkcji, prostsze code review (recenzent skupia się na logice i bezpieczeństwie, nie na spójnikach) i szybsze wychwytywanie miejsc, gdzie np. zabrakło walidacji albo ktoś pominął obsługę błędów.
Jak bezboleśnie modernizować legacy PHP do nowocześniejszego standardu?
Najbezpieczniejsze podejście to stopniowa modernizacja. Zamiast przepisywać wszystko, wprowadzasz małe, konkretne zmiany: wydzielasz serwis z dużej klasy, zamieniasz zlepiany SQL na przygotowane zapytania, dodajesz typy do nowych metod, wprowadzasz Composera i autoloading najpierw tylko dla nowego kodu.
Dobrą praktyką jest zasada: „nowy lub zmieniany kod – zawsze po nowemu”. Jeśli dotykasz modułu fakturowania, porządkujesz go przy okazji: rozbijasz pliki, wprowadzasz testy jednostkowe, podłączasz analizę statyczną. Dzięki temu projekt ewoluuje, a nie staje w miejscu, i nie blokujesz biznesu wielomiesięcznym przepisywaniem systemu.
Jak skonfigurować środowiska dev, stage i produkcję w aplikacji PHP?
Jedno php.ini na wszystko to proszenie się o niespodzianki. Na środowisku developerskim włącz:
display_errors = On, szczegółowe logi błędów,- niższe poziomy cache’owania,
- debugger (Xdebug) tylko lokalnie.
Na produkcji zrób odwrotnie: display_errors = Off, błędy logowane do pliku lub systemu logów, zaostrzone limity (upload, funkcje niebezpieczne), Xdebug wyłączony. Środowisko staging powinno jak najbardziej przypominać produkcję, ale bez prawdziwych danych użytkowników – na nim testujesz wdrożenia, zanim cokolwiek trafi do realnych klientów.
Co warto zapamiętać
- PHP nadal jest jednym z głównych języków backendowych w webie – nie z przyzwyczajenia, ale dlatego, że nowoczesne wersje (7.4, 8.x) oferują wysoką wydajność, bogaty ekosystem i dobrze ogarniają API, mikroserwisy czy integracje z chmurą.
- O jakości projektu decyduje podejście, a nie sam język: „skryptowy” chaos z jednego pliku index.php to kwestia architektury, podczas gdy dobrze ułożona aplikacja PHP ma warstwy, testy, typowanie i rozdzielony widok od logiki.
- Nowoczesna aplikacja PHP opiera się na Composerze i autoloaderze PSR‑4, frameworku (lub lekkim MVC), silnym typowaniu, oddzieleniu HTML/JSON od logiki oraz testach i analizie statycznej – to podstawa zarówno czytelności, jak i bezpieczeństwa.
- W starych systemach kluczowe jest podejście „stopniowej modernizacji”: każdy nowy fragment kodu pisany po nowemu, a stare części poprawiane przy okazji zmian biznesowych (np. wprowadzanie prepared statements zamiast konkatenacji SQL).
- Czytelność i bezpieczeństwo są ze sobą ściśle powiązane: przejrzysta struktura (warstwy, jasne odpowiedzialności) ułatwia umiejscowienie walidacji, autoryzacji i ochrony przed XSS oraz szybkie wyłapywanie niebezpiecznych odstępstw w stylu „jeden dziwny query string wśród prepared statements”.
- Nowe projekty powinny startować co najmniej na PHP 8.1+, a starsze aplikacje potrzebują planu stopniowego upgrade’u – każda przesiadka na wyższą wersję to szansa na pozbycie się deprecacji, starych bibliotek i wprowadzenie typów.







Bardzo ciekawy artykuł! Autor świetnie przedstawił praktyczne wskazówki dotyczące tworzenia czytelnego i bezpiecznego kodu PHP w aplikacjach webowych. Szczególnie podoba mi się fakt, że skupił się nie tylko na aspektach technicznych, ale także na dbałości o czytelność kodu, co ma znaczący wpływ na jego łatwe zrozumienie i dalszą rozbudowę. Jednakże, brakowało mi trochę głębszego zagłębienia się w tematykę bezpieczeństwa aplikacji webowych oraz konkretnych przykładów z życia codziennego programisty. Moim zdaniem, rozszerzenie tych kwestii mogłoby uczynić artykuł jeszcze bardziej wartościowym i praktycznym dla czytelnika. Mimo tego, gorąco polecam lekturę każdemu, kto chce doskonalić swoje umiejętności w tym obszarze!
Możliwość dodawania komentarzy nie jest dostępna.