If you build WordPress sites, you already know most of what a web application needs: routing, database access, templates, forms, users, background jobs. Laravel gives you the same pieces, but makes each one explicit instead of hiding it behind hooks and the template hierarchy. Once you see how the concepts map, the framework feels familiar quickly.
This guide maps WordPress habits to their Laravel equivalents and builds one small feature end to end: a list of property enquiries with a form, validation, a database table, an email sent in the background and a nightly report.
When Laravel is the better toolLink to this section
WordPress is excellent when the core of the project is content: pages, posts, media and editors who need a familiar admin. Laravel is the better fit when the core is application logic:
- Custom workflows such as bookings, quotes, approvals or order processing.
- Complex data with many relationships that don't fit posts and post meta.
- APIs consumed by mobile apps, other services or a separate frontend.
- Strict requirements for tests, deployments and code structure.
Many real projects use both: WordPress for the marketing site and blog, Laravel for the customer portal or internal tool, connected by a REST API.
The concepts, side by sideLink to this section
| WordPress | Laravel | What changes |
|---|---|---|
| Template hierarchy, rewrite rules | Routes (routes/web.php, routes/api.php) |
Every URL is declared explicitly |
functions.php, plugin callbacks |
Controllers, service classes | Logic lives in classes, not hooks |
WP_Query, get_post_meta() |
Eloquent models and query builder | Typed columns instead of meta rows |
dbDelta() on activation |
Migrations | Schema changes are versioned and reversible |
Template parts, get_header() |
Blade layouts and components | Real layout inheritance |
Nonces + current_user_can() |
CSRF middleware + policies and gates | Applied automatically or per route |
wp_mail() |
Mailables | Templated, testable, queueable |
| WP-Cron | Scheduler + queues | Runs from a real cron entry and worker |
wp-config.php constants |
.env file + config/ |
Per-environment settings out of the code |
Routes and controllersLink to this section
In WordPress the URL /enquiries might be a page template, a custom post type archive or a rewrite rule added by a plugin. In Laravel you declare it:
// routes/web.php
use App\Http\Controllers\EnquiryController;
Route::get('/enquiries', [EnquiryController::class, 'index'])->name('enquiries.index');
Route::post('/enquiries', [EnquiryController::class, 'store'])->name('enquiries.store');php artisan route:list prints every URL the application answers, which is the single most useful command when you join an unfamiliar Laravel project.
The controller holds the logic for those routes:
// app/Http/Controllers/EnquiryController.php
namespace App\Http\Controllers;
use App\Http\Requests\StoreEnquiryRequest;
use App\Jobs\NotifySalesTeam;
use App\Models\Enquiry;
class EnquiryController extends Controller
{
public function index()
{
$enquiries = Enquiry::with('property')->latest()->paginate(20);
return view('enquiries.index', compact('enquiries'));
}
public function store(StoreEnquiryRequest $request)
{
$enquiry = Enquiry::create($request->validated());
NotifySalesTeam::dispatch($enquiry);
return redirect()->route('enquiries.index')->with('status', 'Thanks, we will be in touch shortly.');
}
}Migrations and Eloquent instead of post metaLink to this section
In WordPress, an enquiry would probably be a custom post type with its details in wp_postmeta, one row per field. Laravel gives each entity its own table with typed columns, defined in a migration:
// database/migrations/2026_10_21_000000_create_enquiries_table.php
return new class extends Migration {
public function up(): void
{
Schema::create('enquiries', function (Blueprint $table) {
$table->id();
$table->foreignId('property_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('email');
$table->text('message');
$table->enum('status', ['new', 'contacted', 'closed'])->default('new');
$table->timestamps();
$table->index(['status', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('enquiries');
}
};php artisan migrate applies it, and the migration file lives in Git, so every environment gets the same schema. Compare that with dbDelta() on plugin activation, where nobody quite knows which version of the table a site has.
The model describes relationships and which fields may be filled from user input:
// app/Models/Enquiry.php
class Enquiry extends Model
{
protected $fillable = ['property_id', 'name', 'email', 'message'];
public function property(): BelongsTo
{
return $this->belongsTo(Property::class);
}
public function scopeOpen($query)
{
return $query->whereIn('status', ['new', 'contacted']);
}
}Enquiry::open()->where('created_at', '>=', now()->subDay())->count() reads almost like English, and runs a single indexed query instead of a meta_query join.
Watch for the N+1 problem. Looping over enquiries and touching $enquiry->property runs one query per row unless you eager-load with with('property'), exactly like calling get_post_meta() inside a loop without priming the meta cache.
Validation and authorization in one placeLink to this section
WordPress forms need a nonce check, a capability check, sanitizing and validation, written by hand in every handler. Laravel's form requests collect all of that in one class, and CSRF protection is on for every web route by default:
// app/Http/Requests/StoreEnquiryRequest.php
class StoreEnquiryRequest extends FormRequest
{
public function authorize(): bool
{
return true; // public form; use policies for admin actions
}
public function rules(): array
{
return [
'property_id' => ['required', 'exists:properties,id'],
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'email', 'max:190'],
'message' => ['required', 'string', 'max:2000'],
];
}
}If validation fails, Laravel redirects back with the errors and the old input automatically. When you write custom rules with patterns, such as a phone number or a registration ID, test the pattern first in the regex tester.
Blade templatesLink to this section
Blade is to Laravel what template parts are to a theme, with real inheritance. {{ }} escapes output automatically, which is the Laravel equivalent of remembering esc_html() every time:
{{-- resources/views/enquiries/index.blade.php --}}
<x-layout title="Enquiries">
@if (session('status'))
<p class="notice">{{ session('status') }}</p>
@endif
@forelse ($enquiries as $enquiry)
<article>
<h2>{{ $enquiry->name }} · {{ $enquiry->property->title }}</h2>
<p>{{ $enquiry->message }}</p>
</article>
@empty
<p>No enquiries yet.</p>
@endforelse
{{ $enquiries->links() }}
</x-layout>Only use {!! !!} for HTML you have sanitized yourself. It is the echo without escaping that security reviews look for.
Queues instead of slow requestsLink to this section
Sending an email, calling a CRM or resizing images inside the request makes the visitor wait, and a slow third-party API can make the form fail. Laravel moves that work to a queue:
// app/Jobs/NotifySalesTeam.php
class NotifySalesTeam implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $backoff = 60;
public function __construct(public Enquiry $enquiry) {}
public function handle(): void
{
Mail::to(config('sales.email'))->send(new NewEnquiry($this->enquiry));
}
}A worker process (php artisan queue:work, kept alive by Supervisor or the hosting platform) picks jobs up from the database or Redis and retries failures. WordPress has no direct equivalent; WP-Cron runs on page visits, so busy sites run it too often and quiet sites run it late.
The scheduler instead of WP-CronLink to this section
Timed tasks are declared in code and triggered by a single server cron entry that runs every minute:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('reports:daily-enquiries')->dailyAt('07:30')->timezone('Asia/Kolkata');
Schedule::command('enquiries:close-stale')->cron('0 2 * * 1-5');If a cron expression looks right but you're not sure when it fires, paste it into the cron expression builder, which explains it in plain English and lists the next run times.
APIs for a headless or mobile frontendLink to this section
Laravel's API routes return JSON, and API resources control exactly which fields leave the server, much like _fields in the WordPress REST API:
Route::middleware('auth:sanctum')->get('/api/enquiries', function () {
return EnquiryResource::collection(Enquiry::open()->latest()->paginate(20));
});Sanctum handles token authentication for mobile apps and cookie sessions for a frontend on the same domain. Check what your endpoints return with the JSON formatter. The Node.js REST API guide covers the same production concerns, validation, errors, rate limits and logging, from the JavaScript side.
Testing is part of the frameworkLink to this section
WordPress plugin tests need a separate test suite and a test database bootstrap. Laravel ships with testing ready to use, including helpers for HTTP requests, the database and fake mail and queues:
// tests/Feature/EnquiryTest.php
public function test_an_enquiry_is_saved_and_the_team_is_notified(): void
{
Queue::fake();
$property = Property::factory()->create();
$this->post('/enquiries', [
'property_id' => $property->id,
'name' => 'Asha',
'email' => 'asha@example.com',
'message' => 'Is the villa still available?',
])->assertRedirect('/enquiries');
$this->assertDatabaseHas('enquiries', ['email' => 'asha@example.com']);
Queue::assertPushed(NotifySalesTeam::class);
}Factories create realistic test data, RefreshDatabase resets the database between tests, and php artisan test runs everything. A handful of tests like this around the forms and workflows that make money is the best insurance before every upgrade.
Common problems for WordPress developers new to LaravelLink to this section
- "419 Page Expired" on form submit. The form is missing
@csrf. Every POST form in a web route needs it. - Mass-assignment errors. A field isn't listed in the model's
$fillable. That's deliberate protection against users setting fields such asis_admin. - Changes to
.envdon't apply. The configuration is cached. Runphp artisan config:clearlocally; in production rebuild the cache withphp artisan config:cacheafter each deploy. - Jobs never run. No queue worker is running, or
QUEUE_CONNECTIONis stillsync. Check the worker and thefailed_jobstable. - The scheduler does nothing. The server needs one cron line:
* * * * * cd /path && php artisan schedule:run.
Frequently asked questionsLink to this section
Is Laravel harder to learn than WordPress development?Link to this section
It asks for more structure up front: classes, namespaces, Composer and the command line. If you already write plugins with prefixed classes, hooks and $wpdb->prepare(), you have most of the habits; Laravel mostly gives them names and conventions.
Can Laravel and WordPress share a database or users?Link to this section
They can, but it's rarely worth the coupling. A cleaner pattern is to keep each with its own database and connect them through the WordPress REST API or webhooks, so either side can be upgraded independently.
Where should a Laravel app be hosted?Link to this section
Anywhere that runs PHP 8 with Composer, a database and ideally Redis: a VPS with Nginx and PHP-FPM, a managed platform, or containers. Unlike most WordPress hosting, you also need a process manager for queue workers and one cron entry for the scheduler.
Should I choose Laravel, CodeIgniter or CakePHP?Link to this section
It depends on the team, the project size and the existing code. The framework comparison goes through the trade-offs with examples.
If you have a WordPress site that has outgrown plugins, or a Laravel application that needs work, tell me about the project.
Test yourself
3 quick questions on this lesson.
Finished this lesson?
Mark it complete to track your progress through the learning paths.