Jak tworzyć czytelny i bezpieczny kod PHP w nowoczesnych aplikacjach webowych

1
26
3/5 - (1 vote)

Nawigacja po artykule:

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 require w 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?”.

Programiści piszą kod na kolorowych klawiaturach w środowisku bezpieczeństwa
Źródło: Pexels | Autor: Tima Miroshnichenko

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 require w logice biznesowej – tylko vendor/autoload.php w 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 -l lub 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 require w 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.ini lub 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.

Zbliżenie na ekran z kolorowym kodem Ruby on Rails
Źródło: Pexels | Autor: Digital Buggu

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']]);
        // ...
    }
}
Programistka przy biurku pracuje nad bezpieczeństwem kodu na kilku ekranach
Źródło: Pexels | Autor: cottonbro studio

Dane wejściowe: filtrowanie, walidacja i zasada „nie ufaj niczemu”

Oddzielenie filtrowania od walidacji

Rozsądny model przetwarzania danych wejściowych ma dwa kroki:

  1. filtrowanie – konwersja typów, obcięcie białych znaków, normalizacja,
  2. 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 escape zależ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 -l lub 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.
Poprzedni artykułKoza czy kominek: co lepiej grzeje i ile kosztuje?
Następny artykułWkłady kominkowe stalowe czy żeliwne: co lepiej znosi lata palenia?
Martyna Nowak
Martyna Nowak odpowiada za treści o aranżacji i funkcjonalności strefy z kominkiem w domu i domku rekreacyjnym. Łączy wiedzę o materiałach wykończeniowych z praktyką planowania przestrzeni: od bezpiecznych odległości po dobór obudowy, krat i akcesoriów. W artykułach pokazuje, jak pogodzić estetykę z serwisowaniem i czyszczeniem oraz jak uniknąć przegrzewania zabudowy. Inspiracje opiera na realnych realizacjach i konsultacjach z wykonawcami, a nie na samych wizualizacjach.

1 KOMENTARZ

  1. 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.