Every Laravel application on PostgreSQL eventually confronts the primary key question. Auto-incrementing integers are compact and index cleanly, but leak record counts and create friction in distributed or multi-tenant architectures. UUIDv4 solves the predictability problem but indexes badly — random values cause B-tree page splits that degrade write performance on large tables.
PostgreSQL 18, released in September 2025, ships a native uuidv7() function that generates timestamp-ordered UUIDs. The first 48 bits encode a Unix millisecond timestamp; the remaining bits are random. The result is globally unique, unpredictable to external observers, and naturally sortable by creation time within each millisecond.
That ordering property is the one that changes the trade-off.
What makes UUIDv7 different from UUIDv4
A UUIDv4 primary key looks like this: f47ac10b-58cc-4372-a567-0e02b2c3d479. Every hex digit is random. Two consecutive inserts produce values with no predictable relationship. B-tree indexes must insert new values at random positions across the entire key space, causing frequent page splits and index bloat.
A UUIDv7 key generated one millisecond after another looks like this:
018e8f3a-1234-7xxx-yxxx-xxxxxxxxxxxx (insert at 14:23:01.123)
018e8f3a-1235-7xxx-yxxx-xxxxxxxxxxxx (insert at 14:23:01.124)
The high bits are monotonically increasing. New rows land at the right end of the index, just as auto-increment integers do. Index hotspot contention drops, write throughput improves, and the index stays compact.
Enabling UUIDv7 in Laravel on PostgreSQL 18
PostgreSQL 18’s uuidv7() function is built in — no extension required. In a Laravel migration:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('appointments', function (Blueprint $table): void {
// UUIDv7 generated by the database on insert
$table->uuid('id')->primary()->default(DB::raw('uuidv7()'));
$table->string('status');
$table->foreignUuid('patient_id')->constrained('patients');
$table->timestamp('scheduled_at')->useCurrent();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('appointments');
}
};
The database generates the UUIDv7 on insert. Laravel does not need to produce the value before the query runs, which means foreign key relationships work without a pre-generated ID.
If you want Eloquent to generate UUIDv7 values in PHP (to know the ID before the insert), the uuid package by Ramsey generates spec-compliant UUIDv7:
composer require ramsey/uuid
use Ramsey\Uuid\Uuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
final class Appointment extends Model
{
use HasUuids;
public $incrementing = false;
protected $keyType = 'string';
protected function newUniqueId(): string
{
return (string) Uuid::v7();
}
public function uniqueIds(): array
{
return ['id'];
}
}
Laravel’s built-in HasUuids trait calls newUniqueId() before each insert, generating a PHP-side UUIDv7 that the database stores. The trade-off against database-side generation is that the PHP clock is now the source of truth for ordering, which is generally fine for single-region applications.
Sorting and range queries: a practical benefit
Because UUIDv7 encodes the creation timestamp, sorting by ID approximates sorting by creation time for records inserted within the same millisecond boundary. For many query patterns — recent records, pagination, incremental data exports — this removes the need for a separate created_at index on large tables.
-- Efficient: UUIDv7 primary key index satisfies this ordering
SELECT * FROM appointments
ORDER BY id DESC
LIMIT 25;
-- Keyset pagination using UUIDv7 is monotonic and index-friendly
SELECT * FROM appointments
WHERE id < '018e8f3a-xxxx-7xxx-yxxx-xxxxxxxxxxxx'
ORDER BY id DESC
LIMIT 25;
For tables with millions of rows, keyset pagination using UUIDv7 is more efficient than offset pagination using created_at, because the primary key index alone resolves the query without a second index lookup.
When to keep auto-increment integers
UUIDv7 is not the right choice for every table. Auto-increment integers remain preferable when:
- The table is internal and will never be exposed externally. Lookup tables, pivot tables, and audit log entries with no API surface have no reason to carry the overhead of a 16-byte UUID.
- Storage is a meaningful constraint. A UUID primary key costs 16 bytes; a 4-byte integer costs 75% less. On a billion-row events table with extensive indexing, that difference is measurable.
- Referencing code expects sequential integers. Some reporting tools, billing integrations, and legacy export formats depend on numeric IDs and are not worth migrating.
A mixed strategy — UUIDv7 for entities exposed through APIs and integer keys for internal junction tables — is coherent and common.
The upgrade path for existing tables
Converting an existing large table from UUIDv4 to UUIDv7 is a substantial migration. The primary key type is the same (uuid), but existing values will not be timestamp-ordered. New inserts will have UUIDv7 values while existing rows retain UUIDv4 values, making the ordering property unreliable for the table’s lifetime unless a backfill is performed.
For new projects on PostgreSQL 18, UUIDv7 is the better default for API-exposed entities. For existing tables, the migration cost usually exceeds the benefit unless the table is actively experiencing index performance problems.
The feature has a primary source in the PostgreSQL 18 release notes. If your team is evaluating the upgrade, the native uuidv7() function and the asynchronous I/O improvements for sequential scans are the two changes most likely to affect query performance in a typical Laravel application.