How to migrate a Laravel app from MySQL to PostgreSQL: 10-step checklist

Laravel (tested on Laravel 13), MySQL or MariaDB to PostgreSQL. Do the steps in order; each one links a fix that was reproduced on Postgres 17.11 (MySQL side on MariaDB 11.8). Markdown version for AI agents: laravel-mysql-to-postgres.md

1. Scan the MySQL schema before you switch

Paste SHOW CREATE TABLE output (or a mysqldump --no-data file, or Laravel migrations) into the free schema check. It flags what breaks on Postgres: unsigned integers, zero-date defaults, tinyint(1) columns that become boolean, _ci collations, ENUMs and MySQL-only defaults.

SHOW CREATE TABLE users;

See: Free schema risk check

2. Clean the data on MySQL first

Postgres rejects two things MySQL happily stores: zero dates ('0000-00-00') and NUL bytes (0x00) in text. Fix them on the MySQL side, before copying. Through pdo_pgsql a NUL in a bound value silently cuts the string, so this is not optional.

UPDATE orders SET shipped_at = NULL WHERE shipped_at = '0000-00-00 00:00:00'; UPDATE t SET name = REPLACE(name, CHAR(0), '') WHERE INSTR(name, CHAR(0)) > 0;

See: 0000-00-00 date out of range (22008) · invalid byte sequence 0x00 (22021)

3. Convert the schema

Postgres has no unsigned integers, so an unsigned int column's ceiling drops from 4,294,967,295 to 2,147,483,647. Use bigint (unsignedBigInteger() / bigIncrements() in Laravel migrations). The free schema converter turns CREATE TABLE statements into Postgres DDL and prints the setval lines for step 5.

ALTER TABLE events ALTER COLUMN hits TYPE bigint;

See: Free schema converter · out of range for type integer (22003)

4. Copy the data

Use pgloader, or CSV export plus COPY. Copy into a scratch database first and compare row counts per table.

5. Reset every sequence

Rows copied with their ids leave each Postgres sequence at 1, so the first new insert fails with a duplicate key error. Run setval once per table with an id column. It is safe to run again.

SELECT setval(pg_get_serial_sequence('users','id'), COALESCE((SELECT MAX(id) FROM users), 0) + 1, false);

See: duplicate key value violates unique constraint (23505)

6. Fix raw SQL syntax

Laravel's query builder quotes and binds for you; raw SQL (DB::statement, whereRaw, selectRaw, old migrations) does not. Remove backticks, use single quotes for strings, and replace MySQL-only functions: IFNULL to COALESCE, GROUP_CONCAT to string_agg, DATE_FORMAT to to_char, IF() to CASE. The free translator rewrites MySQL SQL to Postgres and explains each change.

IFNULL(a, b) -> COALESCE(a, b) GROUP_CONCAT(email) -> string_agg(email, ',' ORDER BY id)

See: Free SQL translator · syntax error at or near "`" (42601) · column does not exist, double-quoted strings (42703) · function ifnull does not exist (42883)

7. Fix queries that MySQL let slide

Postgres refuses non-grouped columns in GROUP BY queries, and won't compare a boolean or a string column with a bare number written into the SQL. Bound values (->where('active', 1)) still work; literals in raw SQL fail.

WHERE active = true WHERE phone = '5551234'

See: must appear in the GROUP BY clause (42803) · operator does not exist: boolean = integer (42883) · operator does not exist: character varying = integer (42883)

8. Check case-sensitive search and logins

No error here, just missing rows. MySQL _ci collations make = and LIKE case-insensitive; Postgres is case-sensitive. Use ILIKE, lower() on both sides, or citext for email columns. Unique indexes change too: two emails that differ only in case are allowed on Postgres.

WHERE lower(email) = lower(?)

See: LIKE / = finds nothing (case sensitivity)

9. Handle failed statements inside transactions

After one failed statement, Postgres rejects every later statement in the same transaction with 25P02 until rollback. Find the first error in the log. If code must try and continue, use a savepoint (a nested DB::transaction() in Laravel).

See: current transaction is aborted (25P02)

10. Switch the connection and run the tests on Postgres

Set DB_CONNECTION=pgsql and the pgsql settings in .env, then run the full test suite against Postgres, not SQLite or MySQL. Paste any errors into the error matcher to get the matching fix.

DB_CONNECTION=pgsql php artisan test

See: Error matcher (paste a log)

Want it done for you?

The Laravel Postgres Error Fix Pack ($19) has these fixes as tested scripts and a Laravel command. The TuskShift Migration Kit ($49, $34.30 with LAUNCH30 until Nov 5 2026) adds the checker, runbook and cutover plan. Everything above is free.

Published with deed.page