MySQL and MariaDB power most Laravel applications I work on as a freelance developer. They're reliable, well-understood, and Laravel's tooling is built around them. That familiarity breeds complacency, though. The same patterns that work fine at a hundred rows cause real pain at a million. Here's what I've learned working with MySQL and MariaDB on Laravel startup projects day to day.
Why MySQL or MariaDB for Laravel?
The honest answer is: because they work, and the ecosystem expects them. Laravel's Eloquent ORM, query builder, and migrations are all designed with MySQL and MariaDB as the primary targets. Managed hosting options (Forge-provisioned servers, Laravel Cloud, RDS, PlanetScale's MySQL-compatible protocol) all point the same direction. You're not fighting the grain of the framework when you use either one.
MariaDB is a drop-in MySQL replacement for most Laravel applications. The differences that matter in practice are few: MariaDB has moved faster on JSON performance and some query planner improvements; MySQL has a longer enterprise track record. For most startups, the choice is made for you by your hosting provider. Both work well with Laravel. Where I draw distinctions in this post, I'll say so - otherwise, treat the two as interchangeable.
PostgreSQL is a great choice too; Laravel handles it just as well. The reason I default to MySQL or MariaDB isn't technical, it's pragmatic: broader hosting provider support, more Laravel ecosystem examples, and more engineers who've hit your problem before and written about it. That makes MySQL/MariaDB the well-trodden path. If your team is already strong on Postgres or your hosting favours it, use Postgres without hesitation.
Schema Design That Scales
The foundational advice in my post on database design for startups applies here: start normalized. Every piece of data stored once, relationships through foreign keys, integrity enforced at the database level. That post covers the general principles; what follows is the MySQL/MariaDB and Laravel-specific layer on top.
Pick types that mean what you need. Use unsignedBigInteger for foreign keys and primary keys - it matches what Laravel's id() migration helper creates, and unsigned columns can't accidentally store negative values. Never use float for money: floating point introduces rounding errors that show up as a penny here or there in totals, which is a nightmare to chase down. Use integer for minor units (pence, cents) or decimal with explicit precision and scale. Both are correct. Integer in minor units is what Stripe and most payment APIs use, so it lines up well with billing integrations; decimal reads more naturally if you're doing arithmetic in raw SQL. Pick one and stay consistent. Use tinyInteger for boolean-like columns, timestamp for times you need timezone awareness, and date when you only care about the date. Choosing the right type upfront is far cheaper than a migration on a live table with millions of rows.
Enforce referential integrity. Laravel migrations support ->constrained() on foreign keys, which creates an actual database-level constraint. Use it. A constraint means the database will refuse to create an orphaned record even if your application code has a bug. Without constraints, orphaned rows accumulate silently and surface as unexpected nulls, missing related data, or application errors weeks later. The cost of adding a foreign key constraint is low; the cost of debugging orphaned data is high.
Soft deletes: decide early, not later. If you need soft deletes (audit trails, recovery, or regulatory requirements), add softDeletes() to the migration from the start and use Laravel's SoftDeletes trait on the model. Adding soft deletes to a table that already has real deletes, and reconciling what "deleted" means for every query that touches it, is painful. If you don't need them, don't add them - the column adds noise to queries.
Indexes Where They Count
Indexes are extra data structures the database maintains so it can look up rows without scanning the whole table. On large tables they turn slow queries into fast ones. The trade-off is that they aren't free: every insert, update, or delete has to maintain the indexes too, and they take up disk space. So you want indexes that pull their weight, not one on every column.
Which indexes you actually need depends on both your schema and the queries you run against it. Not just the schema. A column that looks important on the diagram may never appear in a WHERE clause; a column that looks unimportant may be the one every query filters on. Profile real query patterns before deciding what to index.
Start with foreign keys. Any column used in a belongsTo or hasMany relationship needs an index. Without one, loading related records for a parent does a full table scan. Laravel's ->constrained() helper adds the index automatically. If you define the foreign key manually, add the index explicitly.
Index columns you filter and sort on. If your users list is paginated by created_at descending, that column needs an index. If you filter orders by status and user_id together, consider a composite index on both in that order. MySQL uses indexes left-to-right: a composite index on (user_id, status) helps queries that filter on user_id alone, but not queries that filter only on status.
Don't index everything. Every index speeds up reads and slows down writes. A table with fifteen indexes on a high-write path is slower to insert into than the same table with three. Add indexes for query patterns you know are real. Use EXPLAIN to verify your indexes are being used. Telescope or a query log will show you what queries are actually running in development.
Unique indexes for business rules. If a user can only have one active subscription, enforce that at the database level with a unique index, not just in application code. Application-level uniqueness checks have a race condition under concurrent requests. The database doesn't.
Migrations as Source of Truth
This is the single habit that makes MySQL and MariaDB manageable across environments: every schema change goes in a migration, every time, without exception.
The temptation on a startup is to make a quick change in the production database directly, because it's faster. It is faster, once. Then your local environment and staging are out of sync with production. A new developer joins and their database is different from yours. You can't roll back. You can't reproduce bugs reliably. The quick change costs hours.
Write the migration, run it, commit it. That's the whole rule. php artisan migrate:status tells you what's run and what hasn't. php artisan migrate:rollback undoes the last batch. Squash migrations periodically (when the application is stable enough) so fresh installs stay fast. For more on why this matters structurally, see Laravel best practices for startups.
One practical point: don't use $table->timestamps() by habit everywhere. On tables where you genuinely don't need created_at and updated_at, the columns are noise. On tables where you do need them, they're invaluable. Think before you add them by default.
Common Pitfalls
N+1 queries
The most common MySQL performance problem in Laravel applications isn't bad indexes - it's the N+1 query problem. You load 50 posts, then for each post Eloquent fires a separate query to load the author. That's 51 queries where 2 would do.
The fix is eager loading: Post::with('author')->get(). Eloquent loads all the related records in a single query. The tricky part is knowing where you have N+1 problems. Laravel Telescope shows every query in development; look for repeated queries with the same structure but different IDs. That pattern is N+1. Fix it with with(), load(), or for counts, withCount().
Type mismatches in joins and comparisons
MySQL will silently coerce types when you compare columns of different types. A join between an integer and a varchar that both happen to contain the same numbers will "work", but MySQL can't use indexes efficiently across the type boundary. It scans instead. This shows up as slow queries on what should be fast joins. The fix is schema consistency: foreign keys and the primary keys they reference should be the same type. If you're joining a column to a value in PHP, make sure the PHP value has the right type before binding it.
Long transactions
Wrapping too much in a single database transaction is a MySQL-specific pain point. Long transactions hold locks and block other queries. An upload handler that starts a transaction, processes a file, calls an external API, then commits - that's a transaction open for as long as the API call takes. If the external call is slow or fails, every other query waiting for those locks waits too.
Keep transactions as short as possible. Do validation and external calls outside the transaction. Open the transaction, write to the database, close it. If you need to coordinate a database write with an external side-effect (sending an email, calling an API), do the database write first, commit, then fire the side-effect from a queued job. That's more resilient anyway - if the job fails, it can retry without re-running the database writes. For more on structuring async work, see scaling your Laravel backend.
Missing column defaults and nullable misuse
Columns that should always have a value should have a database-level default or be NOT NULL without a default (which forces the application to supply one). Columns that can genuinely be absent should be nullable(). Mixing these up causes either insertion failures or a flood of NULLs in columns that should always have data. Check your migration's nullable decisions deliberately, not by habit.
Server-level tuning (memory, buffer sizes, query cache) is a separate rabbit hole and outside the scope of this post. It's also important, especially as your application scales, and something I have an understanding of and experience working with.
When to Reach for Something Else
MySQL and MariaDB are the right default for most Laravel startups in Bath, Bristol, and across the UK. But they're not always the right tool for every part of the system.
PostgreSQL is a peer, not a backup option. Laravel supports it as a first-class citizen and the migration and query builder APIs are almost identical. The reason MySQL/MariaDB is the default here is community gravity: more examples, more hosting support, more "I've seen this before" knowledge in the Laravel ecosystem. If you or your team prefer Postgres, use it without apology.
Redis is the standard complement to MySQL in Laravel applications. Cache expensive query results in Redis, not in the database. Session storage, queue backends, rate limiting, and real-time counters all belong in Redis, not MySQL. Using MySQL for things Redis does well creates unnecessary load on your primary database.
A search service (Algolia, Meilisearch, Typesense) handles full-text search better than MySQL's LIKE queries or even its FULLTEXT indexes for most user-facing search. Laravel Scout integrates with all three. If users are searching across large text columns and complaining about relevance or speed, that's the signal to move search out of MySQL.
The pattern is the same across all of these: use MySQL and MariaDB for relational data where MySQL is strong, and reach for a specialist tool only when you have a real reason. Don't pre-emptively add complexity for scale you may never need.
The Short Version
MySQL and MariaDB with Laravel work well when you follow a few consistent habits: normalized schema, correct types (no float for money), foreign key constraints at the database level, indexes on real query patterns, migrations for every change without exception, and eager loading to stop N+1 queries before they compound. The pitfalls - type mismatches, long transactions, missing constraints - all share the same root cause: treating the database as a detail instead of a foundation.
If you're a startup in Bath, Bristol, or further afield, building on Laravel with MySQL or MariaDB and running into performance or schema problems, reach out. I work as a freelance Laravel developer on exactly these kinds of problems, whether that's a one-off review or ongoing development. For the broader database design picture, see database design for startups.