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

Step-by-step checklist for moving a Laravel app from MySQL to PostgreSQL: scan the schema, clean zero dates and NUL bytes, convert unsigned ints, copy data, reset sequences, fix raw SQL, GROUP BY, boolean and case-sensitivity issues. Each step links a tested fix.

Laravel (tested on Laravel 13), MySQL or MariaDB to PostgreSQL. Fixes reproduced on Postgres 17.11 (MySQL side on MariaDB 11.8). HTML version: https://tuskshift.deed.page/laravel-mysql-to-postgres

## 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.

```sql
SHOW CREATE TABLE users;
```

- Free schema risk check: https://tuskshift.deed.page/

## 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.

```sql
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;
```

- 0000-00-00 date out of range (22008): https://tuskshift.deed.page/laravel-postgres-0000-00-00-date-out-of-range (Markdown: https://tuskshift.deed.page/laravel-postgres-0000-00-00-date-out-of-range.md)
- invalid byte sequence 0x00 (22021): https://tuskshift.deed.page/laravel-postgres-invalid-byte-sequence-utf8-0x00 (Markdown: https://tuskshift.deed.page/laravel-postgres-invalid-byte-sequence-utf8-0x00.md)

## 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.

```sql
ALTER TABLE events ALTER COLUMN hits TYPE bigint;
```

- Free schema converter: https://tuskshift.deed.page/ddl
- out of range for type integer (22003): https://tuskshift.deed.page/laravel-postgres-out-of-range-for-type-integer (Markdown: https://tuskshift.deed.page/laravel-postgres-out-of-range-for-type-integer.md)

## 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.

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

- duplicate key value violates unique constraint (23505): https://tuskshift.deed.page/laravel-postgres-duplicate-key-violates-unique-constraint (Markdown: https://tuskshift.deed.page/laravel-postgres-duplicate-key-violates-unique-constraint.md)

## 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.

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

- Free SQL translator: https://tuskshift.deed.page/translate
- syntax error at or near "`" (42601): https://tuskshift.deed.page/laravel-postgres-syntax-error-at-or-near-backtick (Markdown: https://tuskshift.deed.page/laravel-postgres-syntax-error-at-or-near-backtick.md)
- column does not exist, double-quoted strings (42703): https://tuskshift.deed.page/laravel-postgres-column-does-not-exist-double-quotes (Markdown: https://tuskshift.deed.page/laravel-postgres-column-does-not-exist-double-quotes.md)
- function ifnull does not exist (42883): https://tuskshift.deed.page/laravel-postgres-function-ifnull-does-not-exist (Markdown: https://tuskshift.deed.page/laravel-postgres-function-ifnull-does-not-exist.md)

## 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.

```sql
WHERE active = true
WHERE phone = '5551234'
```

- must appear in the GROUP BY clause (42803): https://tuskshift.deed.page/laravel-postgres-must-appear-in-group-by-clause (Markdown: https://tuskshift.deed.page/laravel-postgres-must-appear-in-group-by-clause.md)
- operator does not exist: boolean = integer (42883): https://tuskshift.deed.page/laravel-postgres-operator-does-not-exist-boolean-integer (Markdown: https://tuskshift.deed.page/laravel-postgres-operator-does-not-exist-boolean-integer.md)
- operator does not exist: character varying = integer (42883): https://tuskshift.deed.page/laravel-postgres-operator-does-not-exist-character-varying-integer (Markdown: https://tuskshift.deed.page/laravel-postgres-operator-does-not-exist-character-varying-integer.md)

## 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.

```sql
WHERE lower(email) = lower(?)
```

- LIKE / = finds nothing (case sensitivity): https://tuskshift.deed.page/laravel-postgres-like-case-sensitive-no-rows (Markdown: https://tuskshift.deed.page/laravel-postgres-like-case-sensitive-no-rows.md)

## 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).

- current transaction is aborted (25P02): https://tuskshift.deed.page/laravel-postgres-current-transaction-is-aborted (Markdown: https://tuskshift.deed.page/laravel-postgres-current-transaction-is-aborted.md)

## 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.

```sql
DB_CONNECTION=pgsql
php artisan test
```

- Error matcher (paste a log): https://tuskshift.deed.page/errors

## Paid shortcuts (optional)

- Laravel Postgres Error Fix Pack, $19, tested scripts for these fixes: https://tuskshift.gumroad.com/l/laravel-postgres-error-fix-pack?utm_source=md-guide
- TuskShift Migration Kit, $49, 30% off with LAUNCH30 until Nov 5 2026: https://tuskshift.gumroad.com/l/tuskshift/LAUNCH30?utm_source=md-guide

TuskShift, AI-assisted.
