Tworzenie efektywnych formularzy na przykładzie Zend Framework 3

Autor:  Jakub Książek
23-05-2019

Tworzenie formularzy nie musi być skomplikowane. Przedstawiam jedną z metod tworzenia formularzy, z zachowaniem najlepszych znanych mi praktyk programistycznych.

Formularze towarzyszą językowi PHP niemal od samego początku jego istnienia. Był nawet taki okres, w którym poprzez fuzję z narzędziem dedykowanym do obsługiwania formularzy zmieniono nazwę języka na PHP/FI od Personal Home Page/Forms Interpreter. Jednak do dziś, mimo wielu lat rozwoju języka oraz postępu w dziedzinie technologii webowych, formularze potrafią stawiać duże wyzwanie i spędzać sen z powiek niejednemu doświadczonemu programiście. Z tego powodu chciałbym przedstawić jedną z metod tworzenia formularzy z zachowaniem najlepszych znanych mi praktyk programistycznych. Opisywana przeze mnie koncepcja zostanie co prawda przedstawiona z wykorzystaniem frameworka Zend Framework 3, jednak jest na tyle ogólna, że w prosty sposób można ją zastosować w innych frameworkach webowych takich jak np. Symfony.

Przechodząc do meritum posłużę się przykładem zaczerpniętym z oryginalnego manuala PHP, w którym możemy znaleźć bardzo prosty przykład napisany w “czystym” PHP:

<form action="action.php" method="post">
<p>Your name: <input type="text" name="name" /></p>
<p>Your age: <input type="text" name="age" /></p>
<p><input type="submit" /></p>
</form>

Powyższy HTML nie zawiera nic szczególnego, jedynie dwa inputy tekstowe z przyciskiem zatwierdzającym wysłanie formularza. Dodatkowo deklarujemy metodę wysyłki formularza i wpisujemy lokalizację skryptu, który ma go obsłużyć. Jak widzimy treść tego formularza będzie obsługiwana przez plik action.php:

Hi <?php echo htmlspecialchars($_POST['name']); ?>. You are <?php echo (int)$_POST['age']; ?> years old.

Skrypt ten dla przykładowych danych wejściowych może zwrócić następujący wynik:

Hi Joe. You are 22 years old.

Tak pokrótce wygląda obsługa formularzy w języku PHP. Przykład ten zawiera podstawowe elementy, które musi posiadać każda implementacja formularza, od przygotowania szablonu HTML po odebranie, przefiltrowanie i przetworzenie danych do wyrenderowania odpowiedzi. Oczywiście tylko nieliczni będą chcieli tworzyć aplikację w ten sposób, bez trójwarstwowego podziału, bez narzędzi integrujących się z bazami danych czy mapującymi strukturę relacyjną na obiektową itd.

Zanim jednak przeniesiemy ten przykład na grunt współczesnych frameworków, zastanówmy się nad miejscem formularzy we wzorcu architektonicznym MVC. Temat ten jest o tyle ważny, że zazwyczaj dostępne na rynku frameworki pozwalają nie tylko obsłużyć formularz przez minimalne ingerowanie w nasze kontrolery, ale udostępniają również dodatkowe narzędzia do renderowania konkretnych pól w warstwie widoku. Tutaj jednak pojawia się pierwsze zagrożenie związane z takimi renderami – zalecaną, chociaż często niewygodną, praktyką jest ustawianie atrybutów HTML w warstwie widoku. Jeśli z jakiegoś powodu nie podoba nam się umieszczanie rozbudowanych funkcji w plikach widoku, warto zastosować tę regułę przynajmniej do części atrybutów, a zwłaszcza przycisków, które z różnych powodów mogą być w ten lub inny sposób wykorzystywane na potrzeby technologii frontendowych – nie będziemy wówczas uzależniać prezentacji od pozostałych warstw aplikacji.

ZF3 pozwala nam dodatkowo oddzielić klasy formularzy od klas walidatorów i filtrów poprzez zastosowanie osobnych komponentów do obu zadań. Takie podejście pozwoli nam z jednej strony podpiąć różne walidatory dla tego samego formularza (tak, są tzw. grupy walidacji, ale nie zawsze nadają się do rozwiązania konkretnego problemu), z drugiej strony pozwala również oddzielić całkowicie proces normalizacji danych od konkretnego formularza – takie rozwiązanie będzie bardzo przydatne np. przy implementacji API lub skryptów konsolowych.

Pozostając jeszcze przy temacie walidatorów i filtrów musimy pamiętać o kwestiach bezpieczeństwa. Podstawowymi funkcjami filtrującymi zabezpieczającymi przed atakami typu XSS są wbudowane w PHP htmlspecialchars oraz strip_tags, natomiast w przypadku ZF3 wystarczy wykorzystać do tego filtr StripTags. Musimy też pamiętać, że nie możemy skończyć na sprawdzeniu, czy nie przychodzą do nas niedozwolone znaki. Przede wszystkim należy upewnić się, czy dane, które przychodzą od klienta, są odpowiedniego typu, a w przypadku ciągów znaków czy są odpowiedniej długości oraz formatu. Warto również oczyścić dane przy pomocy funkcji trim, a w ZF3 filtrów StringTrim oraz w zależności od potrzeb StripNewlines. Nie należy zapominać również o zabezpieczeniu formularza przed atakami typu CSRF, w czym pomagają nam odpowiednie narzędzia zazwyczaj dostarczane z frameworkami.

Stosując się do powyższych rad możemy przepisać nasz przykład z wykorzystaniem komponentów dostarczanych przez ZF3. Stworzymy dwie klasy – jedną dla formularza i drugą dla filtrów oraz walidatorów, ostylujemy go w warstwie widoku a następnie obsłużymy w kontrolerze.

Najłatwiej będzie stworzyć klasę formularza. Najważniejsze będzie możliwie największe oddzielenie logiki formularza od sposobu prezentacji:

class PersonForm extends Form
{
public function init()
{
$this->setAttribute('method', 'POST');

$this->add([
'name' => 'id',
'type' => Hidden::class,
]);

$this->add([
'name' => 'name',
'type' => Text::class,
]);

$this->add([
'name' => 'age',
'type' => Text::class,
]);

$this->add([
'name' => 'csrf',
'type' => Csrf::class,
'options' => [
'csrf_options' => [
'timeout' => 600,
],
],
]);

$this->add([
'name' => 'submit',
'type' => 'submit',
]);
}
}

Wszystkie szczegóły związane ze sposobem wyświetlania naszego formularza zawieramy w pliku index.phtml korzystając z metod setAttribute i setValue w następujący sposób:

<?php $this->form->prepare(); ?>
<?= $this->form()->openTag($this->form); ?>

<?= $this->formHidden($this->form->get('id')) ?>

<?= $this->formElement($this->form->get('name')->setAttribute('placeholder', 'Your name')) ?>
<?= $this->formElementErrors($this->form->get('name')) ?>

<?= $this->formElement($this->form->get('age')->setAttribute('placeholder', 'Your age')) ?>
<?= $this->formElementErrors($this->form->get('age')) ?>

<?= $this->formHidden($this->form->get('csrf')) ?>
<?= $this->formSubmit($this->form->get('submit')->setValue('Send')) ?>

<?= $this->form()->closeTag(); ?>

Następnie przechodzimy do stworzenia filtrów oraz walidatorów:

class PersonInputFilter extends InputFilter
{
public function init()
{
$this->add([
'name' => 'id',
'required' => false,
'validators' => [
[
'name' => Uuid::class
],
],
'filters' => [
['name' => ToNull::class]
],
]);

$this->add([
'name' => 'name',
'required' => true,
'filters' => [
['name' => StripTags::class],
['name' => StringTrim::class],
],
'validators' => [
[
'name' => StringLength::class,
'options' => [
'min' => 6,
'max' => 120
],
],
],
]);

$this->add([
'name' => 'age',
'required' => true,
'filters' => [
['name' => ToInt::class],
],
'validators' => [
['name' => Digits::class],
[
'name' => LessThan::class,
'options' => [
'max' => 128
]
],
[
'name' => GreaterThan::class,
'options' => [
'min' => 1,
'inclusive' => true
]
],
],
]);
}
}

W powyższym przykładzie dodaliśmy ukryte pole z identyfikatorem, żeby można było wypełnić je w przypadku edycji, dodatkowo zabezpieczyliśmy go specjalnym walidatorem dla UUID. Najwięcej walidacji zamieściłem w polu do wpisywania wieku, gdyż powinniśmy sprawdzić czy wprowadzona wartość jest typu cyfrowego. Dodatkowo odpowiednim filtrem rzutujemy wartość na liczbę całkowitą, sprawdzamy również czy zakres wieku mieści się w przedziale od 1 roku do 128 lat, blokując tym samym możliwość wpisywania ujemnych i zbyt dużych wartości. W przypadku imienia walidatorem sprawdzamy, czy wprowadzona dana jest odpowiedniej długości filtrując również wpisany tekst, aby uniemożliwić wykonanie ataków typu XSS.

Teraz możemy przejść do najważniejszego i zarazem najtrudniejszego aspektu tworzenia formularzy webowych, a mianowicie przetwarzania danych. Niejednokrotnie spotkamy się z sytuacją, w której formularz będzie powodował zmiany w logice biznesowej, a my nie chcąc pracować na tablicach przekształcimy je w obiekty. Frameworki takie jak ZF3 oraz Symfony udostępniają w tym celu tzw. mechanizm bindowania obiektów do formularzy.

Wydaje się, że ten mechanizm pozwala nam naprawić całe zło i w szybki sposób dokonywać zmian, których potrzebujemy. W praktyce jednak zbindujemy do formularza encję i co prawda rozwiążemy jeden problem, ale naprawiając go napotkamy szereg innych: najpierw zaczniemy wymuszać na frontend developerze, żeby przysyłał nam datę w ściśle określonym formacie, używając biblioteki ORM napotkamy na problem tworzenia encji z relacjami, nasza encja zostanie wypełniona dodatkową logiką obsługującą wypełnienie jej domyślnymi wartościami, finalnie będziemy mieli problem przy niestandardowych danych i zaczniemy zmieniać obiekt pod wymogi naszego formularza tracąc tym samym całą elastyczność programowania obiektowego, nie wspominając już o tym, że przestrzeganie zasady mówiącej o tym, że encja powinna być zawsze poprawna stanie się wówczas niemożliwe. Tutaj na pomoc przychodzą nam hydratory, których funkcją jest przekształcanie naszych obiektów w tablice, oraz naszych tablic w obiekty. Klasy tego typu idealnie nadają się do obsługiwania formularzy.

Standardowy kod obsługujący nasz formularz będzie wyglądał w następujący sposób:

if ($this->getRequest()->isPost()) {
$this->personForm->setData($this->getRequest()->getPost());

if ($this->personForm->isValid()) {
$data = $this->personForm->getData();
// przetwarzanie danych...
}
}

return new ViewModel([
'form' => $this->personForm
]);

Niestety, tak jak wspominałem, dane zwracane w zmiennej $data są w tym wypadku typu tablicowego, natomiast aby otrzymać w miejscu tablicy obiekt, musimy stworzyć hydrator i podpiąć go pod formularz. W przypadku ZF3 klasa hydratora musi dziedziczyć po abstrakcyjnej klasie AbstractHydrator i implementować dwie podstawowe metody:

  • extract(object $object), która jest odpowiedzialna za przekształcenie obiektu w tablicę;
  • hydrate(array $data, object $object), odpowiedzialnej za wypełnienie obiektu danymi w przekazanej tablicy;

Dla naszego formularza klasa ta będzie wyglądała w następujący sposób:

class PersonHydrator extends AbstractHydrator
{
/**
* @param PersonDTO $object
* @param array $data
* @return PersonDTO
*/
public function hydrate(array $data, $object)
{
$object->id = $data['id'];
$object->name = $data['name'];
$object->age = $data['age'];

return $object;
}

/**
* @param PersonDTO $object
* @return array
*/
public function extract($object)
{
return [
'id' => $object->id,
'name' => $object->name,
'age' => $object->age
];
}
}

Natomiast najlepszym miejsce do zarejestrowania hydratora będzie fabryka, dlatego musimy przekazać do formularza informację z jakim obiektem będzie pracował oraz z którego hydratora powinien skorzystać. Rejestracja hydratora jest bardzo prosta:

$personForm = new PersonForm;

$personForm->setHydrator($container->get('HydratorManager')->get(PersonHydrator::class));
$personForm->setInputFilter($container->get('InputFilterManager')->get(PersonInputFilter::class));
$personForm->setObject(new PersonDTO());

return $personForm;

Teraz, dzięki zastosowaniu się do powyższych instrukcji, nasz formularz zwróci obiekty typu DTO, które będziemy mogli np. przekazać do fasady i w wygodny sposób zbudować poprawną encję.

Powyższa metoda budowania formularzy jest propozycją, która ma na celu budowanie formularzy w sposób efektywny, pozwalający na łatwe rozszerzanie i dostosowywanie podstawowego narzędzia webowego jakim jest formularz do wymagań biznesowych. Mam nadzieję, że zawarte w artykule porady będą przydatne.

Cały projekt wraz z zestawem fabryk pod linkiem: https://github.com/Cubix92/example-form

Przydatne linki:

https://docs.zendframework.com/tutorials/in-depth-guide

https://ocramius.github.io/zf2-best-practices

https://stovepipe.systems/post/avoiding-entities-in-forms

Jeżeli chcesz poszerzyć swoją wiedzę z sprawdź koniecznie artykuł o filtrowaniu i walidacji danych w PHP.

Udostępnij
Autor:  Jakub Książek
Z wykształcenia i usposobienia filozof. Zawodowo związany z technologiami webowymi, głównie językiem PHP chociaż nie stroniącym od JSów. W wolnych chwilach zagłębia się w klasykę literatury fantasy i retrogaming.