<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
БАЗИ ДАНИХ · ПРОЄКТУВАННЯ

Типи данихData types

Тип колонки вирішує, що база погодиться зберегти, як порівняє два значення і скільки місця займе кожен індекс. Помилку типу рідко видно в перший день, її видно на першій тисячі платежів або в 2038 році.

21 хв читання Junior → Senior
01 · ТИП ЦЕ ОБМЕЖЕННЯ

Що база погодиться зберегти

Тип колонки працює як найдешевша перевірка в системі: він відкидає неправильне значення ще до того, як воно стане частиною даних. Невдалий тип відкидає не те. FLOAT спокійно приймає гроші й губить копійки, VARCHAR(5) у SQLite приймає рядок на сто символів, а TIMESTAMP у MySQL працює рівно до 19 січня 2038 року.

зберігають двійковий дріб. 0.1 у двійковому записі нескінченний, як 1/3 у десятковому, тож у колонці лежить найближче наближення, і похибки накопичуються в сумах. зберігає десяткові цифри точно, і PHP отримує його рядком, а не float.

СЛОВНИКFLOAT і DOUBLE. Числа з рухомою комою за IEEE 754: 4 і 8 байтів, близько 7 і 15-17 значущих десяткових цифр. Швидкі й придатні для вимірювань, координат, оцінок. Непридатні для грошей і всього, що порівнюють на рівність.
СЛОВНИКDECIMAL. DECIMAL(12, 2) чи NUMERIC: точне десяткове число, тут 12 цифр, з них 2 після коми. В PostgreSQL DECIMAL і NUMERIC це один тип. PDO віддає його в PHP рядком '0.10', щоб не втратити точність.

MySQL і PostgreSQL перевіряють тип строго: MySQL у , який увімкнений за замовчуванням, PostgreSQL завжди. SQLite має : оголошений тип лише радить, як зберігати, і в колонку INTEGER ляже текст 'yes'. Тому тести на SQLite пропускають значення, на яких продакшн на PostgreSQL впаде.

СЛОВНИКStrict mode. sql_mode зі STRICT_TRANS_TABLES: MySQL відкидає значення, яке не влазить у колонку, з помилкою. Без нього обрізає рядок чи число до межі й лише пише попередження. Увімкнений за замовчуванням з MySQL 5.7, Laravel теж вмикає його в config/database.php ('strict' => true).
СЛОВНИКType affinity. У SQLite тип належить значенню, а не колонці. Оголошений тип колонки (INTEGER, TEXT, NUMERIC, REAL) лише підказує, у що спробувати перетворити значення. Не вийшло перетворити, значення збережеться як є. STRICT-таблиці з SQLite 3.37 це вимикають.
02 · ЩО ДЕ ЗБЕРІГАТИ

Колонка і тип під неї

Майже кожна погана схема зводиться до кількох рішень, ухвалених на автопілоті. Нижче ці рішення для типових колонок сайту вакансій і що з кожним стається на справжніх даних.

колонка і тип
amount FLOAT наближений Десять платежів по 0.1 у FLOAT MySQL дають 1.0000000149011612, а WHERE amount = 0.1 не знаходить жодного з них.
amount DECIMAL(12, 2) точний Сума десяти по 0.10 рівно 1.00. У PHP приходить рядок, рахувати його далі варто через bcmath або brick/math, а не через float.
amount_cents BIGINT ціле Копійки цілим числом: додавання завжди точне, а PHP отримує int. Валюта окремою колонкою, і ділити на 100 треба лише при показі.
status ENUM('draft', 'active', 'closed') жорсткий Компактно і з перевіркою, але новий статус це ALTER TABLE, ORDER BY іде за позицією в списку, а не за абеткою, і прибрати значення з ENUM у PostgreSQL неможливо.
status VARCHAR(20) + PHP enum гнучкий Допустимі значення живуть у backed enum PHP і cast у моделі, за потреби плюс CHECK у базі. Новий статус не вимагає міграції схеми.
is_test BOOLEAN (MySQL) це TINYINT(1) MySQL перетворює BOOLEAN на TINYINT(1) і приймає 5. Для такого рядка is_test = TRUE хибне, а is_test IS TRUE істинне.
published_at TIMESTAMP (MySQL) до 2038 Зберігається в UTC і показується в поясі сесії, але '2038-01-19 03:14:08' UTC уже не вставити. timestampTz() у міграції Laravel на MySQL створює саме цей тип.
published_at timestamptz (PostgreSQL) момент часу Однозначний момент у часі, показ у будь-якому поясі. Межа далеко за 2038 роком.
payload JSON (PostgreSQL) лише текст Зберігає текст як є, з дублями ключів. Немає оператора =, GIN-індексу і @>. Для пошуку всередині документа беріть JSONB.

Кожен рядок перевірено на MySQL 8.0.46 і PostgreSQL 16; виводи нижче.

03 · ГРОШІ

Десять платежів по 0.1

Таблиця payments, десять рядків, у кожному 0.1 у трьох колонках: FLOAT, DOUBLE PRECISION і DECIMAL(12, 2). Сума мала б бути 1.

У MySQL FLOAT це 4 байти і близько 7 значущих цифр, тому похибку видно одразу, а пошук на рівність промахується. У PostgreSQL FLOAT без точності означає DOUBLE PRECISION. PHP поводиться так само: 0.1 + 0.2 == 0.3 дає false.

MySQL 8.0 PostgreSQL 16 SQLite 3.45
1CREATE TABLE payments (
2 id INT PRIMARY KEY,
3 amount_float FLOAT,
4 amount_double DOUBLE PRECISION,
5 amount_decimal DECIMAL(12, 2)
6);
7-- десять рядків (n, 0.1, 0.1, 0.10)
8
9SELECT SUM(amount_float), SUM(amount_double), SUM(amount_decimal)
10FROM payments;
11SELECT COUNT(*) FROM payments WHERE amount_float = 0.1;
+--------------------+--------------------+---------------------+
| SUM(amount_float)  | SUM(amount_double) | SUM(amount_decimal) |
+--------------------+--------------------+---------------------+
| 1.0000000149011612 | 0.9999999999999999 |                1.00 |
+--------------------+--------------------+---------------------+
COUNT(*): 0

Літерал 0.1 у WHERE MySQL порівнює з FLOAT як число подвійної точності, а в колонці лежить одинарне наближення 0.1, тож рівності немає. DOUBLE знайде всі десять рядків, але сума все одно не 1.

1CREATE TABLE payments (
2 id INT PRIMARY KEY,
3 amount_float FLOAT,
4 amount_double DOUBLE PRECISION,
5 amount_decimal DECIMAL(12, 2)
6);
7
8SELECT SUM(amount_float), SUM(amount_double), SUM(amount_decimal)
9FROM payments;
10SELECT 0.1::float8 + 0.2::float8 AS float, 0.1::numeric + 0.2::numeric AS numeric;
        sum         |        sum         | sum
--------------------+--------------------+------
 0.9999999999999999 | 0.9999999999999999 | 1.00

        float        | numeric
---------------------+---------
 0.30000000000000004 |     0.3

Тут FLOAT і DOUBLE PRECISION однакові, обидва float8. Точний лише NUMERIC.

1CREATE TABLE payments (id INT PRIMARY KEY, amount_decimal DECIMAL(12, 2));
2INSERT INTO payments VALUES (1, 0.10);
3
4SELECT typeof(amount_decimal) AS type, 0.1 + 0.2 = 0.3 AS equal
5FROM payments;
┌──────┬───────┐
│ type │ equal │
├──────┼───────┤
│ real │ 0     │
└──────┴───────┘

Точного десяткового типу в SQLite немає: DECIMAL отримує NUMERIC affinity, і 0.10 лягає як REAL. Якщо тести ганяються на SQLite, а гроші в DECIMAL, перевірки сум у тестах і на продакшні можуть розійтися. Копійки цілим числом поводяться однаково всюди.

04 · VARCHAR І TEXT

Довжина, індекс і хто її перевіряє

У PostgreSQL VARCHAR(n) і TEXT зберігаються однаково і працюють однаково швидко; n лише додає перевірку довжини. Тож вибір між ними це питання правила для даних, а не продуктивності. CHAR(n) доповнює значення пробілами до n і майже завжди гірший за обидва.

У MySQL різниця є. TEXT не можна проіндексувати повністю, лише префікс заданої довжини. Ключ індексу InnoDB обмежений 3072 байтами, а utf8mb4 рахує по 4 байти на символ, тож повністю індексується VARCHAR(768) і коротший. Звідси Schema::defaultStringLength(191) у старих проєктах: так лізли в старий ліміт 767 байтів.

MySQL 8.0 PostgreSQL 16 SQLite 3.45
1CREATE TABLE c (name VARCHAR(5), bio TEXT, url VARCHAR(1000));
2
3INSERT INTO c (name) VALUES ('Kvitka Soft');
4CREATE INDEX c_bio_index ON c (bio);
5CREATE INDEX c_url_index ON c (url);
6CREATE INDEX c_url_index ON c (url(191));
ERROR 1406 (22001): Data too long for column 'name' at row 1
ERROR 1170 (42000): BLOB/TEXT column 'bio' used in key specification without a key length
ERROR 1071 (42000): Specified key was too long; max key length is 3072 bytes
-- url(191): індекс лише за першими 191 символами, Sub_part = 191

Префіксний індекс не може бути покриваючим і не допомагає ORDER BY url. Без strict mode перша вставка обрізала б рядок до 'Kvitk' з попередженням.

1CREATE TABLE c (name VARCHAR(5), bio TEXT);
2
3INSERT INTO c (name) VALUES ('Kvitka Soft');
4CREATE INDEX c_bio_index ON c (bio);
5INSERT INTO c (bio) VALUES (/* 3200 символів */);
ERROR:  value too long for type character varying(5)
-- CREATE INDEX на TEXT пройшов
ERROR:  index row size 3216 exceeds btree version 4 maximum 2704 for index "c_bio_index"
HINT:  Values larger than 1/3 of a buffer page cannot be indexed.

Межа B-tree в PostgreSQL це розмір значення, а не оголошений тип: близько 2,7 KB на запис індексу. Довгі тексти індексують хешем (індекс за виразом md5(bio)) або повнотекстовим пошуком.

1CREATE TABLE c (name VARCHAR(5));
2INSERT INTO c VALUES ('Kvitka Soft');
3
4SELECT name, length(name) FROM c;
Kvitka Soft|11

Довжину у VARCHAR(5) SQLite не перевіряє взагалі: тип дає лише TEXT affinity. Валідація довжини має бути в застосунку, інакше тест на SQLite пропустить те, що впаде на PostgreSQL.

05 · ЦІЛІ ЧИСЛА Й BOOLEAN

Межі, переповнення і TINYINT(1)

INT вміщує до 2 147 483 647, UNSIGNED удвічі більше, BIGINT до 9,2 квінтильйона. Лічильник переглядів чи автоінкремент на INT може дійти до межі, і тоді вставка падає. Тому id() у Laravel це BIGINT.

Окремого булевого типу в MySQL немає: BOOLEAN це синонім TINYINT(1), і туди ляже будь-яке число до 127. У PostgreSQL boolean справжній і число 5 не прийме. У SQLite булевих немає, TRUE і FALSE це 1 і 0.

MySQL 8.0 PostgreSQL 16 SQLite 3.45
1CREATE TABLE n (views INT UNSIGNED, delta INT, is_test BOOLEAN);
2-- SHOW CREATE TABLE: `is_test` tinyint(1)
3
4INSERT INTO n (delta) VALUES (2147483648);
5INSERT INTO n (is_test) VALUES (5);
6SELECT is_test, is_test = TRUE, is_test IS TRUE FROM n;
7
8INSERT INTO n (views) VALUES (0);
9UPDATE n SET views = views - 1 WHERE views = 0;
ERROR 1264 (22003): Out of range value for column 'delta' at row 1
+---------+----------------+-----------------+
| is_test | is_test = TRUE | is_test IS TRUE |
+---------+----------------+-----------------+
|       5 |              0 |               1 |
+---------+----------------+-----------------+
ERROR 1690 (22003): BIGINT UNSIGNED value is out of range in '(`lab`.`n`.`views` - 1)'

Віднімання з UNSIGNED, яке йде нижче нуля, падає навіть посеред виразу. Автоінкремент на INT, що дійшов до 2147483647, наступною вставкою дає Duplicate entry '2147483647' for key PRIMARY.

1CREATE TABLE n (delta INT, is_test BOOLEAN);
2
3INSERT INTO n (delta) VALUES (2147483648);
4INSERT INTO n (is_test) VALUES (5);
5INSERT INTO n (is_test) VALUES ('yes') RETURNING is_test;
ERROR:  integer out of range
ERROR:  column "is_test" is of type boolean but expression is of type integer
 is_test
---------
 t

Беззнакових цілих у PostgreSQL немає: unsignedInteger() у Laravel створює звичайний integer, і межа в нього та сама 2 147 483 647. Рядки 'yes', 'on', '1' boolean приймає.

1CREATE TABLE n (delta INT, is_test BOOLEAN);
2INSERT INTO n VALUES (2147483648, 5), (9223372036854775807, 'yes');
3
4SELECT delta, typeof(delta), is_test, typeof(is_test) FROM n;
┌─────────────────────┬───────────────┬─────────┬─────────────────┐
│        delta        │ typeof(delta) │ is_test │ typeof(is_test) │
├─────────────────────┼───────────────┼─────────┼─────────────────┤
│ 2147483648          │ integer       │ 5       │ integer         │
│ 9223372036854775807 │ integer       │ yes     │ text            │
└─────────────────────┴───────────────┴─────────┴─────────────────┘

Ціле в SQLite завжди до 8 байтів, хоч би як колонка була оголошена, а в «булеву» колонку ліг текст. На продакшні обидва рядки впали б.

06 · ДАТА Й ЧАС

DATETIME, TIMESTAMP і timestamptz

У MySQL DATETIME зберігає «стінний» час без поясу: що записали, те й прочитаєте. TIMESTAMP переводить значення з поясу сесії в UTC при записі і назад при читанні, тому той самий рядок у різних сесіях показується по-різному. Ціна TIMESTAMP: 4 байти секунд від 1970 року, і останній момент це 2038-01-19 03:14:07 UTC.

У PostgreSQL timestamp це стінний час, timestamptz це момент у часі, збережений в UTC. Для подій (створено, опубліковано, переглянуто) майже завжди потрібен саме момент. Laravel пише timestampTz() як timestamptz у PostgreSQL і як звичайний TIMESTAMP у MySQL.

MySQL 8.0 PostgreSQL 16 SQLite 3.45
1CREATE TABLE ev (as_datetime DATETIME, as_timestamp TIMESTAMP NULL);
2
3SET time_zone = '+00:00';
4INSERT INTO ev VALUES ('2026-10-08 12:00:00', '2026-10-08 12:00:00');
5SET time_zone = '+03:00';
6SELECT * FROM ev;
7
8SET time_zone = '+00:00';
9INSERT INTO ev VALUES ('2038-01-19 03:14:08', '2038-01-19 03:14:08');
+---------------------+---------------------+
| as_datetime         | as_timestamp        |
+---------------------+---------------------+
| 2026-10-08 12:00:00 | 2026-10-08 15:00:00 |
+---------------------+---------------------+
ERROR 1292 (22007): Incorrect datetime value: '2038-01-19 03:14:08'
  for column 'as_timestamp' at row 1

DATETIME у ту саму мить показав 12:00, TIMESTAMP 15:00. Якщо застосунок і сервер бази живуть у різних поясах, різниця тихо зʼявляється в даних. DATETIME іде до 9999 року.

1CREATE TABLE ev (plain TIMESTAMP, with_tz TIMESTAMPTZ);
2
3SET TimeZone = 'Europe/Kyiv';
4INSERT INTO ev VALUES ('2026-10-08 12:00:00', '2026-10-08 12:00:00');
5SET TimeZone = 'UTC';
6SELECT * FROM ev;
        plain        |        with_tz
---------------------+------------------------
 2026-10-08 12:00:00 | 2026-10-08 09:00:00+00

with_tz зрозумів, що 12:00 це київський час, і зберіг момент. plain лишився 12:00 без жодного поясу, і чий це полудень, база вже не знає.

1CREATE TABLE ev (created_at DATETIME);
2INSERT INTO ev VALUES ('2026-10-08 12:00:00+03:00'), (1791460800);
3
4SELECT created_at, typeof(created_at), datetime(created_at) AS utc FROM ev;
┌───────────────────────────┬────────────────────┬─────────────────────┐
│        created_at         │ typeof(created_at) │         utc         │
├───────────────────────────┼────────────────────┼─────────────────────┤
│ 2026-10-08 12:00:00+03:00 │ text               │ 2026-10-08 09:00:00 │
│ 1791460800                │ integer            │                     │
└───────────────────────────┴────────────────────┴─────────────────────┘

Типу дати в SQLite немає: це текст, число чи REAL, а функції дат розбирають текст ISO 8601. Число як Unix-час читає лише datetime(x, 'unixepoch'). Laravel пише дати текстом 'Y-m-d H:i:s', і порівняння рядків тоді збігається з порівнянням дат.

07 · JSON І JSONB

Текст чи розібраний документ

У PostgreSQL json зберігає текст як є: пробіли, порядок ключів і навіть дублі. jsonb розбирає документ у двійковий вигляд: лишає останнє значення дубля, впорядковує ключі, і на ньому працюють GIN-індекс, оператор @> і порівняння. Для майже всього, що потрібно запитувати, беріть jsonb.

У MySQL JSON один, і він уже двійковий, ближчий до jsonb. Індексують його через згенеровану колонку або багатозначний індекс на масив (MySQL 8.0.17+). SQLite зберігає JSON текстом і з версії 3.45 має функції jsonb для двійкового формату.

PostgreSQL 16 MySQL 8.0 SQLite 3.45
1SELECT '{"title": "Senior PHP", "tags": [1, 2], "title": "Lead"}'::json;
2SELECT '{"title": "Senior PHP", "tags": [1, 2], "title": "Lead"}'::jsonb;
3
4CREATE INDEX ON jj USING gin (a); -- a json
5SELECT b @> '{"tags": ["Laravel"]}' FROM jj; -- b jsonb
 {"title": "Senior PHP", "tags": [1, 2], "title": "Lead"}
 {"tags": [1, 2], "title": "Lead"}
ERROR:  data type json has no default operator class for access method "gin"
 t

json навіть не має оператора =: SELECT a = a падає з operator does not exist: json = json. Тому DISTINCT і GROUP BY по json-колонці теж не працюють.

1SELECT CAST('{"title": "Senior PHP", "tags": [1, 2], "title": "Lead"}' AS JSON);
{"tags": [1, 2], "title": "Lead"}

Як jsonb: дубль ключа відкинуто, лишилось останнє значення, ключі впорядковані. Метод jsonb() у міграції Laravel на MySQL створює той самий json.

1SELECT json('{"title": "Senior PHP", "tags": [1, 2], "title": "Lead"}');
{"title":"Senior PHP","tags":[1,2],"title":"Lead"}

json() лише мінімізує текст і дублі не чіпає. json() і jsonb() у міграції Laravel на SQLite створюють колонку text.

08 · ENUM

Список значень, який важко змінити

ENUM у MySQL зберігає номер значення в списку, тож займає 1-2 байти і не прийме незнайоме значення. Пастки в іншому: сортує за номером, а не за абеткою; додати значення в кінець можна швидко, а вставити в середину означає перебудувати таблицю.

У PostgreSQL ENUM це окремий тип. Значення додаються через ALTER TYPE … ADD VALUE, а прибрати значення не можна взагалі, лише перестворити тип. Laravel цього не використовує: enum() у міграції на PostgreSQL і SQLite створює varchar з CHECK.

MySQL 8.0 PostgreSQL 16
1CREATE TABLE ve (id INT PRIMARY KEY,
2 status ENUM('draft', 'active', 'closed') NOT NULL);
3INSERT INTO ve VALUES (1, 'closed'), (2, 'active'), (3, 'draft');
4
5SELECT id, status, status + 0 AS position FROM ve ORDER BY status;
6INSERT INTO ve VALUES (4, 'archived');
7ALTER TABLE ve MODIFY status
8 ENUM('draft', 'moderation', 'active', 'closed', 'archived') NOT NULL,
9 ALGORITHM=INSTANT;
+----+--------+----------+
| id | status | position |
+----+--------+----------+
|  3 | draft  |        1 |
|  2 | active |        2 |
|  1 | closed |        3 |
+----+--------+----------+
ERROR 1265 (01000): Data truncated for column 'status' at row 1
ERROR 1846 (0A000): ALGORITHM=INSTANT is not supported.
  Reason: Need to rebuild the table to change column type.

Додати 'archived' в кінець списку з ALGORITHM=INSTANT вдалося миттєво. Вставка 'moderation' в середину змінює номери, і MySQL переписує всю таблицю.

1CREATE TYPE vacancy_status AS ENUM ('draft', 'active', 'closed');
2CREATE TABLE ve (id INT PRIMARY KEY, status vacancy_status NOT NULL);
3
4INSERT INTO ve VALUES (4, 'archived');
5ALTER TYPE vacancy_status ADD VALUE 'archived';
6ALTER TYPE vacancy_status DROP VALUE 'draft';
ERROR:  invalid input value for enum vacancy_status: "archived"
-- ADD VALUE пройшов
ERROR:  syntax error at or near "VALUE"

DROP VALUE не існує. Прибрати значення означає створити новий тип, перевести колонку на нього і видалити старий.

09 · ТИПИ В МІГРАЦІЯХ LARAVEL

Що створює кожен метод на кожному драйвері

Схема в міграції одна, а SQL граматика драйвера пише свій. Коментарі нижче взято з граматик laravel/framework 13 для MySQL, PostgreSQL і SQLite і перевірено через migrate --pretend.

php create_payments_table.php Гроші
1$table->decimal('amount', 12, 2);
2// MySQL decimal(12, 2) · PostgreSQL decimal(12, 2) · SQLite numeric
3
4$table->bigInteger('amount_cents');
5$table->char('currency', 3);
6// char(3) · char(3) · varchar
7
8$table->float('rating');
9// float(53), тобто DOUBLE · float(53) · float
ЩО ПЕРЕВІРЯЄ
decimal() без аргументів дає decimal(8, 2), тобто до 999 999.99. У моделі cast 'decimal:2' повертає рядок, бо float знову втратив би точність.
КОЛИ БРАТИ
Гроші в decimal із запасом цифр або цілим у найменших одиницях. float() лише для того, що й так наближене: рейтинг, координати.
php create_payments_table.php Рядки
1$table->string('title', 300); // varchar(300) · varchar(300) · varchar
2$table->string('slug'); // varchar(255) за замовчуванням
3$table->text('description'); // text · text · text
ЩО ПЕРЕВІРЯЄ
string() без довжини бере Builder::$defaultStringLength, 255. У SQLite довжина в SQL не потрапляє зовсім.
КОЛИ БРАТИ
string() з реальною межею для того, що індексують і показують у списках; text() для довгого тексту, який не індексують B-tree.
php create_payments_table.php Дата й час
1$table->timestampTz('published_at');
2// MySQL timestamp · PostgreSQL timestamp(0) with time zone · SQLite datetime
3
4$table->dateTime('expires_at');
5// MySQL datetime · PostgreSQL timestamp(0) without time zone · SQLite datetime
6
7$table->timestampsTz(); // created_at і updated_at
ЩО ПЕРЕВІРЯЄ
На MySQL timestampTz() і timestamp() однакові, обидва TIMESTAMP з межею 2038 року. Точність 0 означає цілі секунди; timestampTz('at', 6) додає мікросекунди.
КОЛИ БРАТИ
Для подій timestampTz() на PostgreSQL. На MySQL для дат після 2038 року dateTime() і явне правило, що в базі лежить UTC.
php create_payments_table.php JSON
1$table->jsonb('tags')->default('[]');
2// MySQL json · PostgreSQL jsonb · SQLite text
3
4$table->json('payload');
5// MySQL json · PostgreSQL json · SQLite text
ЩО ПЕРЕВІРЯЄ
jsonb() на MySQL мовчки стає json, на SQLite обидва методи дають text. whereJsonContains() на PostgreSQL збирає ("tags")::jsonb @> ?, і на колонці jsonb з GIN-індексом план іде через Bitmap Index Scan.
КОЛИ БРАТИ
jsonb() для документів, у яких шукають; json() лише коли важливо зберегти текст як прийшов.
php create_payments_table.php Boolean і enum
1$table->boolean('is_test')->default(false);
2// MySQL tinyint(1) · PostgreSQL boolean · SQLite tinyint(1)
3
4$table->enum('status', ['draft', 'active', 'closed']);
5// MySQL enum('draft', 'active', 'closed')
6// PostgreSQL varchar(255) check ("status" in ('draft', 'active', 'closed'))
7// SQLite varchar check ("status" in ('draft', 'active', 'closed'))
ЩО ПЕРЕВІРЯЄ
cast 'boolean' у моделі перетворить 5 з MySQL на true, тож у PHP пастка TINYINT(1) не видна, а в SQL видна. enum() на PostgreSQL це не ENUM-тип, а CHECK, і змінювати його доведеться через drop і add обмеження.
КОЛИ БРАТИ
boolean() для прапорців. Замість enum() частіше string() плюс backed enum PHP у cast: перелік живе в коді, а нове значення не потребує міграції.
10 · ЯК У PHP UKRAINE

Типи таблиці вакансій

Міграція vacancies показує кілька свідомих рішень. Зарплата лежить цілими числами в одиницях валюти вакансії плюс тризначний код валюти, а поруч ті самі межі в доларах, теж цілими: за ними сайт фільтрує й рахує медіани, і жодного FLOAT. Статус, рівень і формат це string(20), а не enum(): допустимі значення живуть у PHP enum домену (Framework, Level тощо), і новий рівень не вимагає змінювати схему. Усі моменти часу записані timestampTz(), бо продакшн на PostgreSQL і там це timestamptz. Масиви міст і тегів це jsonb з GIN-індексами, які міграція додає лише на PostgreSQL.

php 2026_09_02_000002_create_vacancies_table.php фрагмент, скорочено
1$table->string('level', 20)->nullable()->index();← значення з PHP enum
2$table->jsonb('cities')->default('[]');← jsonb у PostgreSQL, text у SQLite
3$table->integer('salary_from')->nullable();← цілі одиниці валюти
4$table->integer('salary_to')->nullable();
5$table->string('salary_currency', 3)->nullable();
6$table->integer('salary_usd_from')->nullable();← долари для фільтра й медіани
7$table->integer('salary_usd_to')->nullable()->index();
8
9$table->timestampTz('published_at')->index();← timestamptz
10$table->string('status', 20)->default('active');
11$table->unsignedInteger('views')->default(0);← integer у PostgreSQL, unsigned лише в MySQL
12
13if (Schema::getConnection()->getDriverName() === 'pgsql') {
14 DB::statement('CREATE INDEX vacancies_cities_gin ON vacancies USING gin (cities)');← лише на PostgreSQL
15}
database/migrations/2026_09_04_000008_create_market_snapshots.phpЩоденні знімки ринку тримають медіану й квартилі зарплат у integer median_usd, p25_usd, p75_usd: долари без центів, цілими числами.
database/migrations/2026_09_04_000001_add_employer_cabinet.phpcompanies.hiring це json з default('{}'), а applications.created_at і viewed_at це timestampTz.
database/migrations/2026_09_10_000009_add_test_accounts.phpПрапорець is_test через boolean()->default(false): на PostgreSQL boolean, на SQLite у тестах tinyint(1).
11 · ТИПОВІ ПОМИЛКИ

Де тип підводить

✕ 1

Гроші у FLOAT чи double

Суми розходяться на копійку, рівність не знаходить рядків, звіт за місяць не сходиться з банком. У PHP те саме: 0.1 + 0.2 дає 0.30000000000000004.

Натомість: DECIMAL з достатньою кількістю цифр або цілі копійки в BIGINT. У PHP рахувати рядками через bcmath чи brick/math або в цілих копійках.

✕ 2

Тести на SQLite, продакшн на PostgreSQL

SQLite не перевіряє довжину VARCHAR, ціле в boolean, текст у integer і зберігає DECIMAL як REAL. Зелений тест не означає, що значення влізе в колонку на продакшні.

Натомість: Валідація довжини й формату в застосунку, а тести, що залежать від типів, на тому ж рушії, що й продакшн, хоча б у CI.

✕ 3

timestamp у MySQL для дат у майбутньому

Дата закінчення підписки чи гарантії на 15 років уперед не вставиться в TIMESTAMP: межа 2038-01-19. timestampTz() у Laravel на MySQL це той самий TIMESTAMP.

Натомість: dateTime() для дат, що можуть вийти за 2038 рік, і домовленість зберігати UTC.

✕ 4

Час без поясу

DATETIME чи timestamp без tz пишуть те, що дав застосунок. Змінили часовий пояс сервера чи config('app.timezone'), і старі та нові рядки означають різне.

Натомість: timestamptz у PostgreSQL; у MySQL UTC в app.timezone і в з’єднанні, а переведення в київський час лише при показі.

✕ 5

ENUM для того, що росте

Статуси й категорії додаються з кожною фічею. Кожне нове значення в ENUM це міграція схеми, а прибрати значення в PostgreSQL неможливо.

Натомість: string() плюс backed enum PHP у cast моделі, за потреби CHECK. ENUM лишити для справді незмінних наборів.

12 · СЛОВНИК
FLOAT і DOUBLE
Числа з рухомою комою за IEEE 754: 4 і 8 байтів, близько 7 і 15-17 значущих десяткових цифр. Швидкі й придатні для вимірювань, координат, оцінок. Непридатні для грошей і всього, що порівнюють на рівність.
DECIMAL
DECIMAL(12, 2) чи NUMERIC: точне десяткове число, тут 12 цифр, з них 2 після коми. В PostgreSQL DECIMAL і NUMERIC це один тип. PDO віддає його в PHP рядком '0.10', щоб не втратити точність.
Strict mode
sql_mode зі STRICT_TRANS_TABLES: MySQL відкидає значення, яке не влазить у колонку, з помилкою. Без нього обрізає рядок чи число до межі й лише пише попередження. Увімкнений за замовчуванням з MySQL 5.7, Laravel теж вмикає його в config/database.php ('strict' => true).
Type affinity
У SQLite тип належить значенню, а не колонці. Оголошений тип колонки (INTEGER, TEXT, NUMERIC, REAL) лише підказує, у що спробувати перетворити значення. Не вийшло перетворити, значення збережеться як є. STRICT-таблиці з SQLite 3.37 це вимикають.
timestamptz
timestamp with time zone у PostgreSQL. Попри назву, часового поясу не зберігає: на вході переводить момент в UTC, на виході показує в поясі сесії (TimeZone). timestamp без tz зберігає «стінний» час без поясу.
13 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з SQL →

FLOAT і DOUBLE зберігають двійкове наближення, і 0.1 у них не дорівнює рівно 0.1. Похибки накопичуються в сумах і ламають порівняння на рівність: десять по 0.1 у FLOAT MySQL дають 1.0000000149011612. Для грошей беруть DECIMAL або цілі копійки.

У PostgreSQL продуктивність однакова, VARCHAR(n) лише додає перевірку довжини, тож питання в тому, чи є в даних справжня межа. У MySQL TEXT не індексується повністю, лише префіксом, і має інші обмеження, тож для того, що індексують і сортують, беруть VARCHAR з реальною довжиною, а довгий опис кладуть у TEXT.

DATETIME зберігає значення як є, без поясу, до 9999 року. TIMESTAMP переводить з поясу сесії в UTC і назад, займає 4 байти і закінчується 2038-01-19 03:14:07 UTC. timestampTz() на MySQL створює звичайний TIMESTAMP, тож обмеження 2038 року стосується і його. На PostgreSQL той самий метод дає timestamptz.

Перевести колонку на jsonb: ALTER TABLE … ALTER COLUMN tags TYPE jsonb USING tags::jsonb, а на великій таблиці через нову колонку і поступове копіювання, бо зміна типу переписує таблицю під блокуванням. Потім GIN-індекс і запит через оператор @>, який цей індекс використовує. На json ні GIN, ні @> не працюють.