Chyba każdy zna takie powiedzenie, że kiepski szef kuchni nie lubi kosztować przyrządzonych przez siebie potraw.
Niejednokrotnie wpadałem w zachwyt pt. „Napisałem taki super programik, wrzuciłem go na SPOJ i wszystko świeci się na zielono”. Albo np. napisałem klasyczny kalkulator BMI i cieszyłem się, że podaję wagę, wzrost i w zamian dostaję informację, że powinienem schudnąć 🙂
I tutaj wpadłem w małą pułapkę, ponieważ zachowywałem się dokładnie jak ten zły szef kuchni. Nie zadawałem sobie pytań „Co będzie, jak użytkownik poda złe dane?”, ” Co jeśli się pomyli?”, albo „Co jeśli ma złe intencje i celowo wpisze błędne dane, aby jakoś namieszać w moim kodzie?”.
Te kwestie zaczęły mieć dla mnie ogromne znaczenie, kiedy na mojej drodze stanął pakiet XAMPP wraz z językiem PHP, usłyszałem m. in. o wstrzykiwaniu SQL, sanityzacji danych wejścia oraz walidacji.
Nie wiedząc zbyt wiele, próbowałem jakoś to ogarnąć, jednak nadal nie było to doskonałe. Postaram się opisać mój problem na przykładzie napisanej przeze mnie webowej aplikacji budżetowej, znajdującej się pod adresem:
https://finanse.damian-molter.profesjonalnyprogramista.pl
Najpierw postanowiłem stworzyć klasę Validator, która pilnowałaby jakości danych ,wpisywanych przez formularz użytkownika:
<?php
class AwfulValidatorService
{
// Walidacja rejestracji
public function validateRegister(array $formData): array
{
$errors = [];
// Walidacja imienia
if (empty($formData['name'])) {
$errors['name'][] = 'To pole jest wymagane.';
} elseif (strlen($formData['name']) < 3 || !ctype_alpha($formData['name'])) {
$errors['name'][] = 'Podane imię musi składać się wyłącznie z liter i mieć długość co najmniej 3 znaków.';
}
// Walidacja emaila
if (empty($formData['email'])) {
$errors['email'][] = 'To pole jest wymagane.';
} elseif (!filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'][] = 'Niepoprawny adres email.';
}
// Walidacja hasła
if (empty($formData['password'])) {
$errors['password'][] = 'To pole jest wymagane.';
}
// Walidacja potwierdzenia hasła
if (empty($formData['confirmPassword'])) {
$errors['confirmPassword'][] = 'To pole jest wymagane.';
} elseif ($formData['confirmPassword'] !== $formData['password']) {
$errors['confirmPassword'][] = 'Podane hasła nie są identyczne.';
}
return $errors;
}
// Walidacja logowania — kopiujemy email i password z góry...
public function validateLogin(array $formData): array
{
$errors = [];
// Kopia z validateRegister, bo "tak jest łatwiej"
if (empty($formData['email'])) {
$errors['email'][] = 'To pole jest wymagane.';
} elseif (!filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'][] = 'Niepoprawny adres email.';
}
if (empty($formData['password'])) {
$errors['password'][] = 'To pole jest wymagane.';
}
return $errors;
}
// Walidacja transakcji
public function validateTransaction(array $formData): array
{
$errors = [];
// Kolejna kopia logiki "required"
if (empty($formData['amount'])) {
$errors['amount'][] = 'To pole jest wymagane.';
} elseif (!is_numeric($formData['amount'])) {
$errors['amount'][] = 'Only numbers allowed.';
}
if (empty($formData['date'])) {
$errors['date'][] = 'To pole jest wymagane.';
} else {
// Walidacja daty wklejona inline, bo "nie chciało się" robić osobnej metody
$parsedDate = date_parse_from_format('Y-m-d', $formData['date']);
if ($parsedDate['error_count'] !== 0 || $parsedDate['warning_count'] !== 0) {
$errors['date'][] = 'Nieprawidłowa data.';
}
}
return $errors;
}
// Walidacja ustawień konta — znowu te same reguły co wyżej...
public function validateUserAccountSettings(array $formData): array
{
$errors = [];
// Trzecia kopia walidacji imienia
if (empty($formData['name'])) {
$errors['name'][] = 'To pole jest wymagane.';
} elseif (strlen($formData['name']) < 3 || !ctype_alpha($formData['name'])) {
$errors['name'][] = 'Podane imię musi składać się wyłącznie z liter i mieć długość co najmniej 3 znaków.';
}
// Trzecia kopia walidacji emaila
if (empty($formData['email'])) {
$errors['email'][] = 'To pole jest wymagane.';
} elseif (!filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'][] = 'Niepoprawny adres email.';
}
// Walidacja hasła — tym razem NIE jest required, ale nikt nie pamięta dlaczego
// i w validateRegister jest required... niespójność gwarantowana
if ($formData['confirmPassword'] !== $formData['password']) {
$errors['confirmPassword'][] = 'Podane hasła nie są identyczne.';
}
return $errors;
}
// Walidacja limitu — copy-paste "required" po raz czwarty
public function validateLimit(array $formData): array
{
$errors = [];
if (empty($formData['amount'])) {
$errors['amount'][] = 'To pole jest wymagane.';
} elseif ($formData['amount'] < 0) {
// Ale w validateTransaction sprawdzamy is_numeric...
// Tu już sprawdzamy < 0... spójność? Nie słyszałem.
$errors['amount'][] = 'Wpisana wartość nie może być mniejsza od 0.';
}
return $errors;
}
// Jutro ktoś chce dodać walidację numeru telefonu?
// Kopiuje te ify do każdej z powyższych metod. Powodzenia.
}
Jak widać powyżej, mamy tu prawdziwy koszmar dewelopera, który musiałby tę klasę rozszerzać w ramach kary za spóźnianie się do pracy 🙂
Jak teraz ogarnąć to w taki sposób, aby wszystko miało swoje miejsce, kod był łatwy w utrzymaniu, a dodanie nowej reguły nie przyprawiało testerów o palpitację serca? Tutaj ratuje nas wzorzec projektowy Strategia, zacznijmy po kolei.
Wzorzec Strategia polega na zdefiniowaniu rodziny algorytmów (tutaj: reguł walidacji), zamknięciu każdej z nich w osobnej klasie implementującej wspólny interfejs, dzięki czemu można je stosować wymiennie — bez modyfikowania kodu który z nich korzysta (nasz Validator).
Pierwszym co musimy stworzyć, to jakoś ujednolicić nasze reguły, aby ich układ był lekkostrawny dla naszego Validator’a, posłużymy się interfejsem RuleInterface (tzw. międzymordzie):
<?php
interface RuleInterface
{
public function validate(array $data, string $field, array $params): bool;
public function getMessage(array $data, string $field, array $params): string;
}
Nasz interfejs jest bardzo prosty, reguła ma zawierać jedynie metodę sprawdzającą podane przez użytkownika dane, czy są one takie jak chcemy oraz zwracać odpowiedni komunikat w zależności od werdyktu (w przypadku błędu zwraca informację, co jest nie tak). Poniżej przykłady reguł implementujących nasz interfejs:
<?php
class RequireRule implements RuleInterface
{
public function validate(array $data, string $field, array $params): bool
{
return !empty($data[$field]);
}
public function getMessage(array $data, string $field, array $params): string
{
return 'To pole jest wymagane.';
}
}
<?php
// Kopiujemy z: filter_var($formData['email'], FILTER_VALIDATE_EMAIL)
class EmailRule implements RuleInterface
{
public function validate(array $data, string $field, array $params): bool
{
return (bool) filter_var($data[$field], FILTER_VALIDATE_EMAIL);
}
public function getMessage(array $data, string $field, array $params): string
{
return 'Niepoprawny adres email.';
}
}
Oczywiście każda klasa wędruje do osobnego pliku w osobnym folderze o nazwie Rules:

<?php
// Kopiujemy z: strlen($formData['name']) < 3 || !ctype_alpha(...)
class NameRule implements RuleInterface
{
public function validate(array $data, string $field, array $params): bool
{
return strlen($data[$field]) >= 3 && ctype_alpha($data[$field]);
}
public function getMessage(array $data, string $field, array $params): string
{
return 'Podane imię musi składać się wyłącznie z liter i mieć długość co najmniej 3 znaków.';
}
}
<?php
// Kopiujemy z: is_numeric($formData['amount'])
class NumericRule implements RuleInterface
{
public function validate(array $data, string $field, array $params): bool
{
return is_numeric($data[$field]);
}
public function getMessage(array $data, string $field, array $params): string
{
return 'W formularzu mogą być tylko liczby.';
}
}
<?php
// Kopiujemy z: $formData['confirmPassword'] !== $formData['password']
class MatchRule implements RuleInterface
{
public function validate(array $data, string $field, array $params): bool
{
return $data[$field] === $data[$params[0]];
}
public function getMessage(array $data, string $field, array $params): string
{
return 'Podane hasła nie są identyczne.';
}
}
<?php
// Kopiujemy z: date_parse_from_format(...)
class DateFormatRule implements RuleInterface
{
public function validate(array $data, string $field, array $params): bool
{
$parsedDate = date_parse_from_format($params[0], $data[$field]);
return $parsedDate['error_count'] === 0 && $parsedDate['warning_count'] === 0;
}
public function getMessage(array $data, string $field, array $params): string
{
return 'Nieprawidłowa data.';
}
}
<?php
// Naprawiamy niespójność z limitem — łączymy is_numeric i >= 0 w jedną regułę
class MinRule implements RuleInterface
{
public function validate(array $data, string $field, array $params): bool
{
return is_numeric($data[$field]) && $data[$field] >= $params[0];
}
public function getMessage(array $data, string $field, array $params): string
{
return "Wpisana wartość nie może być mniejsza od {$params[0]}.";
}
}
Teraz zajmijmy się naszym Validator’em, który zamiast zawierać setki if’ów, po prostu gromadzi tablicę reguł i używa odpowiednich w zależności od tego, jakie dane mu wysyłamy do walidacji. Oto nasz odchudzony Validator:
<?php
// Validator nie wie NIC o konkretnych regułach
// tylko je wykonuje
class Validator
{
private array $rules = [];
public function add(string $alias, RuleInterface $rule): void
{
$this->rules[$alias] = $rule;
}
public function validate(array $formData, array $fields): void
{
$errors = [];
foreach ($fields as $fieldName => $rules) {
foreach ($rules as $rule) {
// Obsługujemy reguły z parametrami np. 'match:password', 'min:0'
$ruleParams = [];
if (str_contains($rule, ':')) {
[$rule, $ruleParams] = explode(':', $rule);
$ruleParams = explode(',', $ruleParams);
}
$ruleValidator = $this->rules[$rule];
if (!$ruleValidator->validate($formData, $fieldName, $ruleParams)) {
$errors[$fieldName][] = $ruleValidator->getMessage(
$formData,
$fieldName,
$ruleParams
);
}
}
}
if (count($errors)) {
throw new ValidationException($errors);
}
}
}
Na końcu, postanowiłem stworzyć dodatkową klasę ValidatorService, która „rejestruje” wszystkie reguły, używane przez Validatora:
<?php
declare(strict_types=1);
namespace App\Services;
use Framework\Validator;
use Framework\Rules\{
RequireRule,
EmailRule,
NameRule,
MinRule,
InRule,
UrlRule,
MatchRule,
LengthMaxRule,
NumericRule,
DateFormatRule
};
class ValidatorService
{
private Validator $validator;
public function __construct()
{
$this->validator = new Validator();
$this->validator->add('required', new RequireRule());
$this->validator->add('email', new EmailRule());
$this->validator->add('name', new NameRule());
$this->validator->add('min', new MinRule());
$this->validator->add('in', new InRule());
$this->validator->add('url', new UrlRule());
$this->validator->add('match', new MatchRule());
$this->validator->add('lengthMax', new LengthMaxRule());
$this->validator->add('numeric', new NumericRule());
$this->validator->add('dateFormat', new DateFormatRule());
}
public function validateRegister(array $formData)
{
$this->validator->validate($formData, [
'name' => ['required', 'name'],
'email' => ['required', 'email'],
//'age' => ['required', 'min:18'],
//'country' => ['required', 'in:USA,Canada,Mexico'],
//'socialMediaURL' => ['required', 'url'],
'password' => ['required'],
'confirmPassword' => ['required', 'match:password']
//'tos' => ['required']
]);
}
public function validateLogin(array $formData)
{
$this->validator->validate($formData, [
'email' => ['required', 'email'],
'password' => ['required']
]);
}
public function validateTransaction(array $formData)
{
$this->validator->validate($formData, [
'amount' => ['required', 'numeric'],
'date' => ['required', 'dateFormat:Y-m-d']
]);
}
public function validateElement(array $formData)
{
$this->validator->validate($formData, [
'newName' => ['required']
]);
}
public function validateUserAccountSettings(array $formData)
{
$this->validator->validate($formData, [
'name' => ['required', 'name'],
'email' => ['required', 'email'],
'confirmPassword' => ['match:password']
]);
}
public function validateLimit(array $formData)
{
$this->validator->validate($formData, [
'amount' => ['min:0']
]);
}
}
Zatem podsumowując, zamiast, tak jak na początku, po dodaniu nowej reguły, musieć dodawać nowe if’y oraz aktualizować kilka istniejących (konia z rzędem temu, kto się nie pomyli przy tym za pierwszym razem), po prostu tworzymy nową regułę jako nową klasę implementującą interfejs, a następnie dopisujemy tę regułę w odpowiedniej metodzie klasy ValidatorService, która zadba o to, by w razie potrzeby nasz Validator tę regułę użył. Zrobione!
I to wszystko w dzisiejszym artykule. Zainteresowanych czytelników zapraszam do zapoznania się z kodem źródłowym mojego projektu na platformie GitHub:

