deftcode
English
← Powrót do bloga

Podstawy PHP w pigułce: Typy danych i rzutowanie

Przeczytaj po angielsku
phpkurs phptypy

O czym jest ta część

W poprzedniej części włączyliśmy declare(strict_types=1) i powiedziałem, że to najważniejsza linijka w pliku. Teraz zobaczysz dlaczego — bo pod spodem PHP jest językiem, który bardzo chce zamienić jedną rzecz w drugą, i robi to według reguł, których nie da się wywnioskować z intuicji.

Ta część to katalog tych reguł. Nie musisz umieć wszystkiego na pamięć — zwrócę uwagę na dwie rzeczy, które naprawdę trzeba mieć w głowie: listę wartości falsy i zasady porównywania operatorem ==. Reszta to materiał, do którego możesz wracać w razie potrzeby.


Jak PHP traktuje typy

PHP jest typowany dynamicznie — informację o typie niesie wartość przypisana do zmiennej, a nie zmienna sama w sobie. Ta sama zmienna może po kolei trzymać liczbę, string i tablicę, a interpreter w żaden sposób nie będzie protestował.

W praktyce znaczy to, że zmiennej nie da się otypować. Deklaracje typów istnieją w PHP tylko w trzech miejscach:

function f(int $x): string { } // ✅ parametr i typ zwracany
class A
{
public int $count = 0; // ✅ właściwość klasy (od PHP 7.4)
}
int $x = 2; // ❌ błąd składni — takiej konstrukcji po prostu nie ma
$x = 2; // ✅ jedyna możliwa forma

Sam język tego braku nie nadrabia — nie ma składni, którą dałoby się przypilnować typu zwykłej zmiennej. Nadrabiają go za to narzędzia analizy statycznej: PHPStan i Psalm czytają kod bez uruchamiania go i potrafią wyłapać, że w zmiennej wylądowało coś innego, niż wynika z reszty programu. Rozumieją też adnotację @var, która dla samego PHP jest zwykłym komentarzem, a dla nich deklaracją wiążącą:

/** @var int $count */
$count = $repository->count();

W praktyce to właśnie one zasypują tę lukę i w poważniejszych projektach PHP są dziś standardem — wrócimy do nich w serii o narzędziach.

PHP jest też typowany słabo — domyślnie zamienia typy w locie, żeby operacja mogła się wykonać. To drugie wyłączasz przez declare(strict_types=1), ale tylko tam, gdzie wartość jest gdzieś przekazywana: przy podawaniu argumentu do funkcji, przy zwracaniu z niej wyniku i przy przypisaniu do typowanej właściwości klasy. W zwykłych wyrażeniach ta zamiana — nazywana żonglowaniem typami — działa dalej, niezależnie od trybu ścisłego.

src/Example.php
<?php
declare(strict_types=1);
function double(int $n): int
{
return $n * 2;
}
double('5'); // TypeError — TU pilnuje, bo wartość idzie do funkcji
$x = '5' + 3; // int(8) — TU nie pilnuje, to zwykłe wyrażenie
var_dump('5' == 5); // true — porównanie też działa jak zawsze

To rozróżnienie jest ważne, bo łatwo wyjść z założenia, że jedna linijka „naprawia typowanie w PHP”. Ona pilnuje tych trzech miejsc. Cała reszta zostaje Twoją odpowiedzialnością.

Kategorie typów

Kategoria Typy
Skalarne bool, int, float, string
Złożone array, object, callable, iterable
Specjalne null, resource

resource to relikt — wskazanie na coś, czym zarządza sam PHP poza Twoim kodem: otwarty plik, połączenie z bazą, strumień. Nowe rozszerzenia PHP konsekwentnie zamieniają go na normalne obiekty, więc spotkasz go głównie w starszym API.

Sprawdzanie typu

var_dump($x); // typ + wartość — najlepsze do debugowania
gettype($x); // 'integer', 'double', 'boolean' — stare nazwy, NIE używaj
get_debug_type($x); // 'int', 'float', 'bool', 'App\User' — PHP 8, używaj TEGO
is_int($x); is_float($x); is_string($x); is_bool($x);
is_array($x); is_null($x); is_object($x);
is_callable($x); is_iterable($x); is_numeric($x);

Tak wygląda wyjście var_dump() dla kilku typowych wartości — zwróć uwagę, że przy stringu podaje jeszcze jego długość, a przy tablicy liczbę elementów:

var_dump(42); // int(42)
var_dump(4.2); // float(4.2)
var_dump('hi'); // string(2) "hi"
var_dump(true); // bool(true)
var_dump(null); // NULL
var_dump([1, 'a']); // array(2) { [0]=> int(1) [1]=> string(1) "a" }

Różnica między gettype() a get_debug_type() nie jest kosmetyczna. gettype() zwraca 'double' dla floata i 'integer' dla inta — nazwy, których nie da się wstawić do deklaracji typu ani porównać z nazwą klasy. Dla obiektu zwraca po prostu 'object'. get_debug_type() zwraca dokładnie to, co napisałbyś w kodzie, łącznie z pełną nazwą klasy. Do komunikatów błędów i logów jest jedynym sensownym wyborem.

is_numeric() sprawdza, czy string da się potraktować jak liczbę: is_numeric('1e3') to true, is_numeric(' 12') też, bo spacja z przodu jest dozwolona. Za to is_numeric('12 '), ze spacją na końcu, zwraca true dopiero od PHP 8.0.


Rzutowanie

Rzutowanie jawne

(int) '12abc'; // 12 — parsuje wiodącą część liczbową i przestaje
(int) 'abc'; // 0
(int) ''; // 0
(float) '1.5e3'; // 1500.0
(bool) '0'; // false ← zaskakujące
(bool) '0.0'; // true ← jeszcze bardziej
(string) true; // '1'
(string) false; // '' ← pusty string, NIE '0'
(array) $obj; // publiczne właściwości stają się kluczami

Dwie rzeczy z tej listy wracają najczęściej. Po pierwsze (int) na stringu nie jest walidacją — '12abc' zamienia się w 12 bez cienia ostrzeżenia. Jeśli sprawdzasz dane wejściowe, użyj filter_var() albo ctype_digit(), nigdy samego rzutowania.

Po drugie (string) false daje pusty string, a nie '0'. To regularnie psuje sklejanie stringów i zapisy do bazy.

Funkcyjne odpowiedniki

intval('0x1A', 16); // 26 — drugi argument to podstawa systemu
floatval('1.5abc'); // 1.5
strval(3.0); // '3'
boolval('0'); // false
settype($x, 'integer'); // zmienia typ samej zmiennej $x, zwraca bool

intval() z podstawą to jedyna rzecz, której (int) nie umie. Poza tym wybieraj składnię z nawiasami, czyli (int) '12abc' zamiast intval('12abc') — jest czytelniejsza i szybsza.

Rzutowanie na obiekt

(object) ['a' => 1]; // stdClass z właściwością ->a
(object) [1, 2]; // klucze liczbowe stają się właściwościami "0" i "1"
$o->{0}; // ← wymagana składnia z klamrami; $o->0 to błąd parsowania
(object) 'hi'; // stdClass { scalar: 'hi' } — tak samo dla int/float/bool
(object) null; // pusty stdClass

W praktyce spotkasz to przy JSON-ie, bo json_decode() domyślnie zwraca stdClass, a nie tablicę:

json_decode($json); // stdClass — DOMYŚLNIE
json_decode($json, true); // tablica asocjacyjna

Prawie zawsze chcesz true w drugim argumencie — ale nie dlatego, że tablica jest „bezpieczniejsza”. Ani tablica, ani stdClass nie mają zadeklarowanej struktury: w obu przypadkach IDE nie podpowie Ci nazw pól, a sięgnięcie po nieistniejące skończy się ostrzeżeniem i wartością null. Przewaga tablicy jest czysto praktyczna — działa na niej ??, destrukturyzacja i cały zestaw funkcji array_*, a zagnieżdżone dane nie mieszają Ci tablic z obiektami. Prawdziwym rozwiązaniem jest własna klasa i do tego wrócimy w części dziesiątej.


Wartości falsy

Falsy to wartości, które PHP uznaje za fałsz wszędzie tam, gdzie spodziewa się warunku. Po polsku mówi się o nich „wartości fałszywe”, ale w praktyce usłyszysz raczej angielskie falsy i truthy.

Ta lista jest krótka i warto ją znać na pamięć, bo decyduje o zachowaniu każdego if, while, empty() i operatora ?: w Twoim kodzie.

false
0
-0
0.0
-0.0
''
'0'
[]
null

Wszystko inne jest truthy. W szczególności truthy są:

'0.0' // string, który wygląda jak zero
' ' // spacja
'false' // string 'false'
'[]' // string wyglądający jak pusta tablica
-1 // każda liczba różna od zera, także ujemna
0.00001
[0] // tablica z jednym fałszywym elementem — sama nie jest pusta
new stdClass() // każdy obiekt, nawet bez właściwości

Najczęstsza wpadka to '0'. Jeżeli formularz przysyła numer strony, ilość albo kod pocztowy, wartość '0' przejdzie przez if ($value) jak brak danych:

$qty = '0';
if ($qty) { } // ❌ nie wejdzie — '0' jest fałszywe
if ($qty !== '') { } // ✅ jawne sprawdzenie tego, o co Ci chodzi
if (isset($qty)) { } // ✅ gdy pytasz o istnienie, nie o wartość

Porównania: == kontra ===

Obiecałem to na końcu poprzedniej części, więc zaczynamy od najważniejszej zmiany w PHP 8.

=== porównuje typ i wartość. == najpierw żongluje typami, potem porównuje wartości. I dokładnie te reguły żonglerki zostały w PHP 8 przepisane.

// PHP 7 PHP 8
0 == 'foobar'; // true false
0 == ''; // true false
'abc' == 0; // true false

Stara reguła brzmiała: przy porównaniu liczby ze stringiem string jest rzutowany na liczbę. Ponieważ 'abc' rzutowane na int daje 0, wychodziło 0 == 'abc'. To było jedno z najczęstszych źródeł błędów bezpieczeństwa w PHP: sprawdzenie w stylu $hasloZFormularza == 0 wychodziło prawdą dla dowolnego tekstu.

Nowa reguła jest odwrotna i o wiele rozsądniejsza: jeśli string nie jest liczbowy, to liczba jest rzutowana na string i porównanie idzie tekstowo. Jeśli string jest liczbowy — obie strony porównywane są jako liczby:

'1' == '01'; // true — oba stringi liczbowe, porównane jako liczby
'10' == '1e1'; // true — notacja wykładnicza to też liczba
100 == '1e2'; // true
'abc' == 0; // false — 'abc' nie jest liczbowe, więc 0 staje się '0'
null == false; // true
[] == false; // true
Wyrażenie Wynik Dlaczego
0 == 'abc' false string nieliczbowy → '0' == 'abc'
0 == '0' true string liczbowy → porównanie liczbowe
'1' == '01' true oba liczbowe → 1 == 1
'abc' == 'ABC' false == na stringach nie ignoruje wielkości liter
null == 0 true oba fałszywe
null === 0 false różne typy

Reguła praktyczna: używaj ===, dopóki nie masz konkretnego powodu, żeby zrobić inaczej. Ta zasada oszczędzi Ci więcej czasu niż zapamiętanie całej tabeli powyżej.

Operator <=> — po angielsku spaceship operator, czyli statek kosmiczny, bo tak wygląda — porównuje dwie wartości i mówi, która jest większa: zwraca -1, gdy lewa jest mniejsza, 0, gdy są równe, i 1, gdy lewa jest większa. Dokładnie takiej odpowiedzi oczekują funkcje sortujące, którym podajesz własną regułę porównania:

1 <=> 2; // -1
[1, 2, 3] <=> [1, 2, 3]; // 0
usort($users, fn($a, $b) => $a->age <=> $b->age);

Pełną tabelę operatorów i pułapki priorytetów omówimy w części czwartej. Tutaj chodziło wyłącznie o to, co == robi z typami.


Liczby całkowite

PHP_INT_MAX; // 9223372036854775807 — największy int na maszynie z 64-bitowym procesorem
PHP_INT_MIN; // -9223372036854775808 — i najmniejszy
PHP_INT_SIZE; // 8 — ile bajtów zajmuje jeden int (8 bajtów to właśnie 64 bity)

Przepełnienie po cichu zmienia typ

W większości języków int, który wyjdzie poza swój zakres, przewija się na drugi koniec — w Javie czy C# dodanie jedynki do największego inta daje nagle liczbę ujemną:

// Java
Integer.MAX_VALUE // 2147483647
Integer.MAX_VALUE + 1 // -2147483648 ← przewinęło się na drugi koniec zakresu

PHP nie robi ani tego, ani nie rzuca wyjątkiem. Wynik po prostu zmienia typ na float:

var_dump(PHP_INT_MAX); // int(9223372036854775807)
var_dump(PHP_INT_MAX + 1); // float(9.2233720368548E+18) ← to już nie jest int

Nie ma ostrzeżenia. Jeśli dalej w kodzie zakładasz int, dostaniesz TypeError w zupełnie innym miejscu niż źródło problemu.

Zapis literałów

0b1010; // dwójkowo = 10
0o17; // ósemkowo = 15 (składnia z PHP 8.1)
017; // ósemkowo = 15 (stara składnia — łatwa do przeoczenia)
0x1A; // szesnastkowo = 26
1_000_000; // separator cyfr (7.4+) = 1000000

Stara notacja ósemkowa to realna pułapka: 017 nie jest siedemnastką. Jeśli wklejasz uprawnienia plików albo kody, wiodące zero zmienia znaczenie liczby.

Podkreślenia to funkcja parsera, nie formatu danych. W stringu nic nie znaczą: (int) '2_00' daje 2, a nie 200 — rzutowanie zatrzymuje się na pierwszym znaku niebędącym cyfrą.


Liczby zmiennoprzecinkowe

PHP_FLOAT_MAX; // 1.7976931348623157E+308 — największa liczba, jaką float udźwignie
PHP_FLOAT_MIN; // 2.2250738585072E-308 — najmniejsza DODATNIA, nie najmniejsza w ogóle
PHP_FLOAT_DIG; // 15 — tyle cyfr znaczących jest jeszcze wiarygodnych
PHP_FLOAT_EPSILON; // 2.220446049250313E-16 — najmniejsza różnica, jaką float w ogóle odróżni

Zwróć uwagę na PHP_FLOAT_MIN — w odróżnieniu od PHP_INT_MIN nie jest to najmniejsza możliwa liczba, tylko najmniejsza dodatnia. Najmniejszą w ogóle jest -PHP_FLOAT_MAX.

Nigdy nie porównuj floatów przez ==

var_dump(0.1 + 0.2 == 0.3); // false

To nie jest wada PHP — tak działa IEEE 754, czyli standard zapisu liczb zmiennoprzecinkowych, którego używa praktycznie każdy język. Ułamków takich jak 0.1 nie da się zapisać dokładnie w systemie dwójkowym, więc pod spodem siedzi liczba odrobinę inna od tej, którą wpisałeś. Najłatwiej to zobaczyć, wypisując wynik z pełną dokładnością:

echo 0.1 + 0.2; // 0.3 ← tak to WYGLĄDA
var_dump(0.1 + 0.2); // float(0.30000000000000004)
printf('%.20f', 0.1 + 0.2); // 0.30000000000000004441 ← tak jest NAPRAWDĘ

echo zaokrągla przy wypisywaniu do czternastu cyfr znaczących, więc pokazuje ładne 0.3 — i to właśnie dlatego błąd jest tak trudny do wyśledzenia. Porównanie widzi prawdziwą wartość, nie tę wypisaną. Poprawnie sprawdza się więc, czy różnica mieści się w granicy błędu:

var_dump(abs((0.1 + 0.2) - 0.3) < PHP_FLOAT_EPSILON); // true

Granicą błędu jest tu PHP_FLOAT_EPSILON ze stałych powyżej: różnica mniejsza niż ona oznacza, że dla typu float to po prostu ta sama liczba.

Ten sam mechanizm psuje zaokrąglanie w dół:

floor((0.1 + 0.7) * 10); // 7, nie 8 — klasyczna pułapka reprezentacji binarnej

Pieniądze

Nigdy nie trzymaj kwot w typie float. Masz dwie sensowne opcje:

// 1. Liczby całkowite w najmniejszej jednostce (grosze) — najprostsze i najszybsze
$price = 1999; // 19,99 zł
// 2. Rozszerzenie bcmath / gmp, gdy potrzebujesz arytmetyki dziesiętnej
bcadd('0.1', '0.2', 2); // '0.30'

W części dziesiątej zbudujemy z tego value object Money, który pilnuje jednostki i waluty za Ciebie.

Dzielenie, reszta i zaokrąglanie

intdiv(10, 3); // 3 — dzielenie całkowite: resztę ucina, nie zaokrągla
intdiv(10, 0); // DivisionByZeroError — dzielenie przez zero rzuca wyjątkiem
10 % 3; // 1 — reszta z dzielenia, działa TYLKO na liczbach całkowitych
10.5 % 3; // 1 — 10.5 zostaje po cichu ucięte do 10, dopiero potem reszta
-10 % 3; // -1 ← znak wyniku bierze się z LEWEJ strony, nie z prawej
fmod(10.5, 3); // 1.5 — reszta dla floatów, zachowuje część ułamkową
fdiv(1, 0); // INF — jedyne dzielenie przez zero, które NIE rzuca wyjątku
fdiv(-1, 0); // -INF
fdiv(0, 0); // NAN — bo 0/0 nie ma żadnego sensownego wyniku
floor(7.9); // 7.0 — zaokrągla w dół, ale zwraca FLOAT, nie int
ceil(7.1); // 8.0 — zaokrągla w górę, też FLOAT
round(2.5); // 3.0 — połówki zaokrągla w górę, czyli w stronę od zera
round(-2.5); // -3.0 — stąd minus dwa i pół idzie do minus trzech
round(2.345, 2); // 2.35 — drugi argument to liczba miejsc po przecinku

Dwie rzeczy do zapamiętania: floor() i ceil() zwracają float, więc pod strict_types=1 nie wstawisz ich wprost tam, gdzie wymagany jest int — potrzebujesz (int) floor($x) albo intdiv(). I % zawsze pracuje na intach, więc 10.5 % 3 po cichu zamieni 10.5 na 10.

Zmiana w PHP 8: 1 / 0 rzuca teraz DivisionByZeroError zamiast wypisywać ostrzeżenie i zwracać INF. Jeśli świadomie chcesz INF, użyj fdiv().

NAN; // stała: „to nie jest liczba" (Not a Number) — wynik działania bez sensownej wartości
INF; // stała: nieskończoność
log(-1); // NAN — logarytm z liczby ujemnej nie istnieje
sqrt(-1); // NAN — pierwiastek z ujemnej też nie
PHP_FLOAT_MAX * 2; // INF — wynik nie mieści się już w typie float
is_nan(sqrt(-1)); // true — jedyny sposób, żeby wykryć NAN (patrz niżej)
is_infinite(PHP_FLOAT_MAX * 2); // true — czy to nieskończoność
is_finite(1.5); // true — zwykła liczba: ani NAN, ani nieskończoność

NAN ma jeszcze jedną cechę, która zaskakuje: nie równa się samemu sobie. NAN == NAN to false, więc do sprawdzenia użyj is_nan().


Stringi

Apostrofy kontra cudzysłowy

To pierwsza rzecz, która różni PHP od większości języków — rodzaj cudzysłowu zmienia zachowanie.

$name = 'Paweł';
echo 'Cześć $name\n'; // dosłownie: Cześć $name\n
echo "Cześć $name\n"; // Cześć Paweł + znak nowej linii

Apostrofy traktują tekst dosłownie: $name zostaje w wyniku jako $name, a \n jako ukośnik i litera n. Rozumieją tylko dwa zapisy z ukośnikiem: \' wstawia apostrof w środku tekstu, a \\ sam ukośnik.

Cudzysłowy robią dwie rzeczy więcej. Po pierwsze interpolują, czyli w miejsce nazwy zmiennej wstawiają jej wartość. Po drugie traktują ukośnik jako zapowiedź znaku, którego nie da się wpisać z klawiatury wprost: \n to przejście do nowej linii, \t tabulator, \u{1F600} emoji. Takie zapisy nazywa się sekwencjami ucieczki (escape sequences) i działają tylko w cudzysłowach.

Przy bardziej złożonych wyrażeniach potrzebne są klamry:

echo "Wartość: {$obj->prop['key']}"; // klamry wymagane
echo "Tablica: $arr[key]"; // klucz BEZ cudzysłowów — tylko w składni prostej
echo "Tablica: {$arr['key']}"; // składnia z klamrami — z cudzysłowami

Praktyczna reguła: używaj apostrofów domyślnie, cudzysłowów gdy naprawdę interpolujesz. Nie chodzi o wydajność (różnica jest pomijalna), tylko o to, że apostrof jasno mówi „tu nie ma nic do podstawienia”.

Heredoc i nowdoc

Heredoc zachowuje się jak cudzysłów — interpoluje:

$html = <<<HTML
<div class="card">
<h1>$title</h1>
</div>
HTML;

Od PHP 7.3 znacznik zamykający może być wcięty, a to wcięcie jest usuwane z każdej linii treści. Znacznik nie może być wcięty bardziej niż treść — to błąd parsowania.

Nowdoc zachowuje się jak apostrof — nie interpoluje niczego:

$sql = <<<'SQL'
SELECT * FROM users WHERE name = '$name'
SQL;

Nowdoc jest właściwym wyborem wszędzie tam, gdzie tekst zawiera znaki dolara, których nie chcesz podstawiać: zapytania SQL, szablony, fragmenty JavaScriptu, wyrażenia regularne.

Najczęściej używane funkcje

Funkcja Co robi Przykład → wynik
strlen($s) Długość w bajtach strlen('żółw') → 7
mb_strlen($s) Długość w znakach mb_strlen('żółw') → 4
str_contains($h, $n) Czy zawiera fragment (PHP 8) str_contains('Hello', 'ell') → true
str_starts_with() / str_ends_with() Test prefiksu / sufiksu (PHP 8) str_starts_with('Hello', 'He') → true
strpos($h, $n) Pozycja pierwszego wystąpienia albo false strpos('Hello', 'l') → 2
substr($s, $start, $len) Wycinek stringa substr('Hello', 1, 3) → 'ell'
str_replace($find, $rep, $subj) Podmienia każde wystąpienie str_replace('l', 'L', 'Hello') → 'HeLLo'
sprintf($fmt, ...) Buduje sformatowany string sprintf('%05.2f', 3.5) → '03.50'
number_format($n, $dec) Formatuje liczbę do wyświetlenia number_format(1234.5, 2) → '1,234.50'
trim() / ltrim() / rtrim() Obcina białe znaki lub podane znaki trim(' hi ') → 'hi'
explode($delim, $s, $limit) Dzieli string na tablicę explode(',', 'a,b,c') → ['a','b','c']
implode($glue, $arr) Skleja tablicę w string implode('-', ['a','b']) → 'a-b'
str_pad($s, $len, $pad, $type) Dopełnia do stałej szerokości str_pad('7', 3, '0', STR_PAD_LEFT) → '007'
ucfirst() / ucwords() Wielka pierwsza litera / każdego słowa ucwords('hello world') → 'Hello World'
strtolower() / strtoupper() Zmiana wielkości liter strtolower('HeLLo') → 'hello'
hash_equals($known, $user) Porównanie odporne na atak czasowy do tokenów i HMAC-ów, nigdy ===

Ostatni wiersz wymaga słowa komentarza. hash_equals() porównuje dwa stringi zawsze przez tyle samo czasu, niezależnie od tego, na którym znaku się różnią. Zwykłe === kończy pracę na pierwszej różnicy — a mierząc, jak długo trwała odpowiedź serwera, da się zgadywać sekret znak po znaku. Dlatego tokeny, podpisy i skróty haseł porównuje się wyłącznie przez hash_equals().

Pułapki z tej tabeli

strpos('Hello', 'H'); // 0 — a to jest FAŁSZYWE. Zawsze porównuj !== false
// albo użyj str_contains(), gdy chcesz tylko tak/nie
str_replace('l', 'L', $s, $n); // $n jest PRZEZ REFERENCJĘ — dostaje liczbę podmian
explode('', 'abc'); // ValueError — pusty separator
sprintf('%1$s %1$s', 'hi'); // 'hi hi' — %N$s używa argumentu numer N ponownie
trim($s, 'abc'); // drugi argument to LISTA ZNAKÓW, nie fragment tekstu
rtrim('file.php', 'hp.'); // 'file' — usuwa znaki h, p i kropkę, nie sufiks '.php'
number_format($n, 2, ',', ' '); // 3. i 4. argument: separator dziesiętny i tysięcy

Pierwsza z nich jest klasykiem. strpos() zwraca 0, gdy szukany fragment jest na początku — a 0 jest wartością falsy, więc if (strpos($s, $n)) przeoczy trafienie na pozycji zerowej. Od PHP 8 masz str_contains() i w większości przypadków to jest odpowiedź.

Ostatni argument trim() też regularnie zaskakuje: to zbiór znaków do obcięcia, nie sufiks. rtrim('file.php', '.php') zadziała przypadkiem, ale rtrim('graph.php', '.php') zwróci 'gra', bo obetnie też h z graph.

Bajty kontra znaki

Wszystkie funkcje bez przedrostka mb_ operują na bajtach, nie na znakach. Dla ASCII to bez różnicy. Dla czegokolwiek po polsku różnica jest ogromna.

strlen('żółw'); // 7 — bajty w UTF-8
mb_strlen('żółw'); // 4 — znaki
Diagram: słowo żółw jako cztery znaki i jako siedem bajtów UTF-8, gdzie ż, ó i ł zajmują po dwa bajty
W UTF-8 polskie znaki diakrytyczne zajmują dwa bajty. strlen() liczy bajty, mb_strlen() znaki.

Konsekwencje wychodzą wszędzie tam, gdzie tniesz albo mierzysz tekst użytkownika:

substr('żółw', 0, 3); // 'żó' + połowa znaku — uszkodzony UTF-8
mb_substr('żółw', 0, 3); // 'żół'
strtoupper('żółw'); // 'żółw' — nie ruszy polskich liter
mb_strtoupper('żółw'); // 'ŻÓŁW'
str_pad('żółw', 6); // doda 0 spacji — bo strlen to już 7

Funkcje mb_* nie są częścią samego PHP — pochodzą z rozszerzenia mbstring, czyli dodatkowego modułu, który uczy PHP liczyć znaki zamiast bajtów. Bez niego mb_strlen() po prostu nie istnieje i dostaniesz błąd o nieznanej funkcji. Zwykle jest już włączone, a sprawdzisz to przez extension_loaded('mbstring').

Gdy je masz, traktuj mb_* jako domyślny wybór dla wszystkiego, co pochodzi od użytkownika albo do niego trafia. Funkcje bajtowe zostaw do danych binarnych.


Null

null to typ z dokładnie jedną wartością. W PHP dochodzi do tego zestaw konstrukcji, które różnią się subtelnie — i to właśnie te różnice są źródłem błędów.

isset($x); // false dla null; NIE ostrzega o niezdefiniowanej zmiennej
empty($x); // true dla każdej wartości falsy; też nie ostrzega
is_null($x); // true tylko dla null; OSTRZEGA gdy $x nie istnieje
$x ?? 'domyślna'; // podstawia, gdy $x nie istnieje albo jest null; nie ostrzega
$x ??= 'domyślna'; // to samo, ale od razu przypisuje wynik do $x (7.4)
$obj?->method(); // operator nullsafe (8.0)
Sprawdzenie Niezdefiniowana null '' 0 '0' []
isset() false false true true true true
empty() true true true true true true
is_null() ⚠ ostrzeżenie true false false false false
?? podstawia tak tak nie nie nie nie

Ta tabela mówi wszystko: isset() pyta o istnienie, empty() o prawdziwość. Jeśli zależy Ci na tym, żeby 0 i '0' były poprawnymi wartościami — a przy ilościach, cenach i identyfikatorach zależy — empty() jest złym narzędziem.

Operator nullsafe

$country = $user?->getAddress()?->country;

Kluczowa właściwość: ?-> przerywa cały łańcuch. Jeśli $user jest null, to getAddress() w ogóle się nie wykona, country nie zostanie odczytane, a całe wyrażenie da null. Nie musisz stawiać ?-> przy każdym ogniwie „na wszelki wypadek” — wystarczy tam, gdzie faktycznie może pojawić się null.

?? kontra ?:

Oba skracają zapis „weź to, a jak nie ma, to tamto”. Różnią się tym, co dla nich znaczy „nie ma”.

$v = $a ? $a : $b; // pełny zapis: jeśli $a jest prawdziwe, weź $a, w przeciwnym razie $b
$v = $a ?: $b; // to samo, tylko krócej — $a nie trzeba pisać dwa razy
$v = $a ?? $b; // podobne, ale sprawdza COŚ INNEGO

?: — po angielsku Elvis operator, bo obrócony przypomina fryzurę i oczy — patrzy na prawdziwość, czyli sprawdza dokładnie to samo, co if ($a). Podstawi wartość zapasową dla każdej wartości falsy z listy z początku tej części.

?? patrzy na istnienie. Podstawia tylko wtedy, gdy zmiennej albo klucza w ogóle nie ma, albo gdy siedzi tam null.

Najlepiej widać to na trzech przypadkach tego samego parametru z adresu:

// 1. parametru nie ma w adresie
$params = [];
$params['page'] ?: 1; // 1 + Warning: Undefined array key "page"
$params['page'] ?? 1; // 1 — bez ostrzeżenia
// 2. parametr jest, ale pusty: ?page=
$params = ['page' => ''];
$params['page'] ?: 1; // 1 — '' jest fałszywe, więc leci wartość zapasowa
$params['page'] ?? 1; // '' — klucz istnieje, więc zwraca to, co w nim siedzi
// 3. parametr to zero: ?page=0
$params = ['page' => '0'];
$params['page'] ?: 1; // 1 ← PUŁAPKA: '0' jest fałszywe, strona zerowa znika
$params['page'] ?? 1; // '0' — poprawnie

Prawie zawsze chodzi Ci o ??: dane wejściowe albo są, albo ich nie ma, a '' czy '0' bywają poprawnymi wartościami. Po ?: sięgaj wtedy, gdy naprawdę interesuje Cię prawdziwość — na przykład $nazwa ?: 'Anonim', gdzie pusty string faktycznie ma zostać zastąpiony.

Na koniec pułapka, która potrafi zaskoczyć przy przepisywaniu kodu z innego języka: zagnieżdżone operatory trójargumentowe od PHP 8 wymagają nawiasów. Wcześniej działały, ale wiązały się od lewej — odwrotnie niż w większości języków — więc a ? b : c ? d : e znaczyło (a ? b : c) ? d : e. Od 7.4 PHP ostrzegał, od 8.0 jest to błąd składni:

// ❌ PHP 8: Unparenthesized `a ? b : c ? d : e` is not supported
$label = $score > 90 ? 'świetnie' : $score > 50 ? 'nieźle' : 'słabo';
// ✅ nawiasy mówią wprost, co z czym
$label = $score > 90 ? 'świetnie' : ($score > 50 ? 'nieźle' : 'słabo');
// ✅ zwykle czytelniej — o match w części czwartej
$label = match (true) {
$score > 90 => 'świetnie',
$score > 50 => 'nieźle',
default => 'słabo',
};

Co dalej

Wiesz już, co PHP trzyma w zmiennych i według jakich reguł zamienia jedno w drugie. Brakuje struktury, która w PHP obsługuje właściwie wszystko: tablicy.

W następnej części zobaczysz, dlaczego jedna konstrukcja pełni w PHP rolę listy, mapy, zbioru, stosu i kolejki naraz, jak działają klucze (i dlaczego '1' oraz 1 to ten sam klucz), oraz które z pięćdziesięciu funkcji array_* warto faktycznie znać.

Komentarze