Різниця, яку відчуваєш щодня, починається з міграцій. У PostgreSQL CREATE TABLE, ALTER TABLE, DROP INDEX і навіть CREATE TYPE виконуються всередині транзакції, тому міграція з п'яти кроків або застосовується повністю, або не застосовується зовсім. Laravel це враховує: Migrator обгортає міграцію в транзакцію лише тоді, коли граматика схеми повертає supportsSchemaTransactions() === true, а це pgsql і sqlite, але не mysql. На MySQL кожен DDL-оператор робить implicit commit відкритої транзакції, тож якщо впав третій ALTER TABLE, перші два залишились у схемі, а запис у таблиці migrations так і не з'явився. Версія 8.0 принесла atomic DDL, і окремий оператор більше не залишає після краху сервера напіврозібрану таблицю, проте відкотити виконаний DDL командою ROLLBACK не вийде й там. Звідси звичка тримати міграції на MySQL короткими та ідемпотентними: чистити доведеться руками.
Система типів у PostgreSQL дозволяє описати в схемі те, що в MySQL осідає в JSON або в додаткових таблицях. Масив text[] з GIN-індексом і оператором @> закриває теги й стек технологій. int4range і daterange зберігають інтервал одним значенням, мають оператори перетину && та вкладення, а разом з EXCLUDE USING gist (плюс розширення btree_gist) дають обмеження «два бронювання однієї переговорки не можуть перетинатися в часі», яке неможливо обійти жодним застосунком. ENUM тут окремий тип через CREATE TYPE, і його перевикористовують кілька колонок. У MySQL ENUM належить колонці, зберігається як індекс у списку значень, і переставити порядок означає переписати дані. Пастка для тих, хто мігрує: $table->enum() у Laravel на pgsql не створює тип, а генерує varchar(255) з check-обмеженням, тому реальні типи заводять через DB::statement.
PostgreSQL суворіший там, де MySQL прощає. Кожна неагрегована колонка в SELECT має бути або в GROUP BY, або функційно залежати від згрупованого первинного ключа (це вміння з'явилось у 9.1), тому групування по v.id автоматично легалізує v.title та v.status. MySQL до 5.7.5 просто повертав довільний рядок групи; ONLY_FULL_GROUP_BY тепер у sql_mode за умовчанням, але його регулярно вимикають, і тоді запит «працює», видаючи неузгоджені дані. З типами те саме: MySQL порівнює varchar-колонку з числом, зводячи обидва боки до double, через що індекс не використовується, а '12abc' = 12 повертає true; PostgreSQL відповідає operator does not exist: character varying = integer. Додайте до цього колацію: типова utf8mb4_0900_ai_ci робить порівняння нечутливим до регістру й діакритики, тому WHERE email = 'Ivan@Example.com' на MySQL знайде рядок, а на PostgreSQL ні, поки ви не візьмете citext або виразний індекс по lower(email).
У коді на PHP найчастіше бракує саме RETURNING. Він працює для INSERT, UPDATE і DELETE, тому UPDATE accounts SET balance = balance - 100 WHERE id = 1 RETURNING balance віддає нове значення без другого запиту й без гонки, а UPDATE ... WHERE id IN (SELECT ... FOR UPDATE SKIP LOCKED) RETURNING * атомарно забирає порцію задач із черги. У MySQL 8 такої конструкції немає (в MariaDB INSERT ... RETURNING є з 10.5), а LAST_INSERT_ID() після багаторядкової вставки повертає id лише першого рядка. Поруч стоїть екосистема розширень: pg_trgm робить LIKE '%текст%' індексованим, unaccent знімає діакритику в пошуку, pg_stat_statements показує найдорожчі запити, pgvector додає тип для embeddings. І типи індексів, яких у MySQL немає: GIN, GiST, BRIN, часткові та виразні індекси.
Зворотний бік теж є. PostgreSQL виділяє окремий процес на кожне з'єднання, тому PHP-FPM із сотнями воркерів чи Octane вимагають PgBouncer, а transaction-режим пулера ламає серверні prepared statements, advisory locks і LISTEN/NOTIFY (PgBouncer відстежує prepared statements лише з 1.21). MVCC-роздування потребує уваги до autovacuum, послідовності дають дірки в id, бо nextval не відкочується. MySQL натомість простіший у реплікації, стоїть на будь-якому хостингу й залишається єдиним варіантом для WordPress та купи легасі. На питання «що брати» відповідайте так: PostgreSQL для нового продукту з непростою предметною областю, бо транзакційний DDL, типи й суворість ловлять помилки раніше, і одразу уточніть, що працюючий проєкт на MySQL 8 переносять, коли впираються в конкретну відсутню можливість, а не з принципу.
-- PostgreSQL: DDL у транзакції, при помилці не залишиться ні типу, ні таблиці
BEGIN;
CREATE TYPE vacancy_status AS ENUM ('draft', 'published', 'archived');
CREATE TABLE vacancies (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
status vacancy_status NOT NULL DEFAULT 'draft',
stack text[] NOT NULL DEFAULT '{}', -- масив замість таблиці-довідника
salary int4range, -- «від-до» одним полем, з операторами
published_at timestamptz
);
-- GIN по масиву: пошук «усі вакансії зі Laravel у стеку» без join-ів
CREATE INDEX vacancies_stack ON vacancies USING gin (stack);
-- Частковий індекс: у дерево потрапляють лише опубліковані рядки
CREATE INDEX vacancies_published ON vacancies (published_at DESC)
WHERE status = 'published';
COMMIT; -- ROLLBACK тут скасував би все; у MySQL CREATE TABLE уже був би зафіксований
-- RETURNING: id і серверний дефолт одним запитом, без LAST_INSERT_ID()
INSERT INTO vacancies (status, stack, salary, published_at)
VALUES ('published', '{Laravel,PostgreSQL}', int4range(2500, 4000), now())
RETURNING id, published_at;
-- @> - масив містить елемент, && - діапазони перетинаються
SELECT id
FROM vacancies
WHERE stack @> ARRAY['Laravel'] AND salary && int4range(3000, 3500);
-- Суворий GROUP BY: id у GROUP BY робить title функційно залежним і дозволеним
SELECT v.id, v.status, count(a.id) AS applications
FROM vacancies v LEFT JOIN applications a ON a.vacancy_id = v.id
GROUP BY v.id;
Назвіть одну річ, яка змінює щоденну роботу: транзакційний DDL. Далі один тип, який ви реально використовували (`daterange` з `EXCLUDE`, `text[]` з GIN), і одну пастку міграції (неявне приведення типів у MySQL, якого PostgreSQL не прощає). Три конкретні факти переконують більше, ніж список із двадцяти пунктів.