Starting a Project
composer create-project laravel/laravel my-app # create a new project
cd my-app
composer global require laravel/installer # or, install the installer once
laravel new my-app # then create projects with it
composer install # install PHP dependencies (vendor/ is gitignored)
cp .env.example .env # create the local environment file
php artisan key:generate # write APP_KEY into .env
php artisan migrate # create the database tables
composer run dev # server + queue worker + Vite, all in one terminal
- The four
composer install→migratesteps above are what a fresh clone of an existing project needs. .envis not committed, which is why a cloned project has none.APP_KEYencrypts sessions and cookies. Without it, nothing boots.composer run dev(orphp artisan servealone) is for development only; production runs behind Nginx/Apache or FrankenPHP.- A new project defaults to SQLite:
laravel newalready createsdatabase/database.sqliteand runs the migrations for you.
Starter Kits
laravel new prompts you to pick a starter kit: a normal Laravel app that
already ships authentication and a scaffolded frontend, fully inside your
project for you to edit.
- React — Inertia, React 19, TypeScript, Tailwind, shadcn/ui. Frontend in
resources/js/. - Vue — Inertia, Vue 3 Composition API, TypeScript, Tailwind, shadcn-vue. Frontend in
resources/js/. - Svelte — Inertia, Svelte 5, TypeScript, Tailwind, shadcn-svelte. Frontend in
resources/js/. - Livewire — Livewire, Tailwind, Flux UI. No JS framework, all Blade and PHP. Frontend in
resources/views/. - None — the plain skeleton, for learning the framework or building an API.
laravel new my-app # prompts for the kit
laravel new my-app --using=vendor/starter-kit # a community kit from Packagist
- The React, Vue, and Svelte kits use Inertia to keep server-side routing and controllers, with no separate API layer.
- Teams — an option in the same prompt. Adds team management screens and
team-scoped routes (
/{current_team}/dashboard). - WorkOS AuthKit — another option. Swaps the built-in auth for a hosted
provider with social login, passkeys, magic links, and SSO. Needs
WORKOS_CLIENT_ID,WORKOS_API_KEY, andWORKOS_REDIRECT_URLin.env.
Every kit uses Laravel Fortify for authentication, configured in
config/fortify.php:
use Laravel\Fortify\Features;
'features' => [
Features::registration(), // remove this line to close public sign-ups
Features::resetPasswords(),
Features::emailVerification(),
Features::twoFactorAuthentication(['confirm' => true]),
],
- Registration logic lives in
app/Actions/Fortify/CreateNewUser.php. - On the Inertia kits, disabling a feature also means removing the routes it used from your frontend components, or the build fails.
- Breeze and Jetstream were the previous generation of starter kits; current docs no longer list them, so use the kits above for new projects.
- This kit generation shipped in Laravel 12; the Svelte kit and Fortify-powered auth came in Laravel 13.
Local Environment: Herd and Sail
php artisan serve assumes PHP, Composer, and a database are already
installed and configured. Herd and Sail remove that setup work.
Herd (macOS / Windows)
herd park ~/Sites # serve every project in this folder
herd link my-app # serve a single project as my-app.test
herd php -v # the PHP binary Herd manages
herd open # open the current project in the browser
- Herd is a native app bundling PHP, Composer,
and nginx. Projects in a parked directory are served automatically at
<folder>.test. - Switch PHP versions per project from the UI or
herd use php@8.3. - Herd bundles the
php,composer,laravel,node,npm, andnvmbinaries; that’s whynodemay be missing from yourPATHelsewhere. - The paid tier adds databases, a mail catcher, and log viewing.
Sail (Docker)
php artisan sail:install # add Sail to an existing project
./vendor/bin/sail up -d # start the containers
./vendor/bin/sail down # stop them
php artisan serve → sail artisan serve
php artisan migrate → sail artisan migrate
composer install → sail composer install
npm run dev → sail npm run dev
php artisan tinker → sail artisan tinker
- Wraps Docker Compose with PHP, MySQL, Redis, and more, so nothing is
installed on the host. Prefix every command with
sail. - Shorten it with a shell alias:
alias sail='[ -f sail ] && sh sail || sh vendor/bin/sail'. - Running a bare
php artisan migrateon the host is a common mistake; it targets the host’s PHP, not the container’s database. - In
.env,DB_HOSTis the service name (mysql), not127.0.0.1.
Herd and Sail change where the commands run, not what they do. Everything else in this cheat sheet is identical either way.
Architecture: MVC
Laravel follows the MVC pattern. A request flows through the app in one direction:
Request → routes/web.php → Controller → Model (Eloquent) → Database
↓
View (Blade) → Response
- Route (
routes/web.php,api.php) — maps a URL and HTTP verb to code. - Controller (
app/Http/Controllers/) — receives the request, orchestrates, responds. - Model (
app/Models/) — represents a table, holds queries and relations. - View (
resources/views/) — Blade templates that render HTML.
app/Http/Middleware/ # code that runs before/after every request
bootstrap/app.php # app bootstrap: routing, middleware, exceptions
config/ # configuration files, read from .env
database/migrations/ # versioned schema changes
database/seeders/ # sample/initial data
public/ # web root, entry point (index.php) and built assets
resources/js|css/ # frontend source, compiled by Vite
routes/ # route definitions
storage/ # logs, cache, compiled views, uploaded files
- Keep controllers thin: validate, delegate, return. Business logic belongs in models or dedicated classes, not in the controller.
Everyday Artisan Commands
php artisan serve # run the dev server
php artisan serve --port=8001 # on another port
php artisan tinker # interactive REPL with the app booted
php artisan route:list # every registered route
php artisan route:list --path=user # filtered
php artisan about # environment, versions, cache status
artisanis the CLI.php artisan listshows everything;php artisan help <command>explains one.
Generators
php artisan make:model Post -mcr # model + migration + controller + resource routes
php artisan make:model Post -mfc # model + migration + factory + controller
php artisan make:model Post -m # model + migration
php artisan make:controller PostController --resource
php artisan make:migration create_posts_table
php artisan make:request StorePostRequest
php artisan make:middleware EnsureUserIsAdmin
php artisan make:seeder PostSeeder
php artisan make:factory PostFactory
php artisan make:command SyncPosts
Database
php artisan migrate # run pending migrations
php artisan migrate --path=database/migrations/2024_01_01_000000_create_posts_table.php # run one specific migration
php artisan migrate:status # what ran and what didn't
php artisan migrate:rollback # undo the last batch
php artisan migrate:fresh --seed # drop everything, re-migrate, seed
php artisan db:seed # run seeders only
migrate:freshdeletes all data. Never run it against production.
Cache and Maintenance
php artisan optimize:clear # clear config, route, view and app caches at once
php artisan config:clear
php artisan cache:clear
php artisan view:clear
php artisan storage:link # link public/storage → storage/app/public
php artisan queue:work # process queued jobs
php artisan down # maintenance mode
php artisan up
- In production, cache instead:
php artisan config:cache route:cache view:cache. - In development, keep them cleared, since cached config ignores
.envchanges.
Routing
// routes/web.php
use App\Http\Controllers\PostController;
Route::get('/', fn () => view('welcome'));
Route::get('/posts', [PostController::class, 'index'])->name('posts.index');
Route::post('/posts', [PostController::class, 'store']);
Route::get('/posts/{post}', [PostController::class, 'show']);
Route::put('/posts/{post}', [PostController::class, 'update']);
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
// All seven CRUD routes at once
Route::resource('posts', PostController::class);
// Grouping
Route::middleware('auth')->prefix('admin')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
});
- Type-hinting the model (
show(Post $post)) enables route model binding: Laravel fetches the record and 404s automatically if it doesn’t exist. - Use named routes and
route('posts.index')instead of hardcoded URLs. routes/web.phphas sessions and CSRF;routes/api.phpis stateless.
Controllers
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function index()
{
return view('posts.index', ['posts' => Post::latest()->paginate(15)]);
}
public function show(Post $post) // route model binding
{
return view('posts.show', compact('post'));
}
public function store(Request $request)
{
$data = $request->validate([
'title' => ['required', 'string', 'max:255'],
'body' => ['required'],
]);
$post = Post::create($data);
return redirect()->route('posts.show', $post)->with('status', 'Post created!');
}
}
Eloquent
// Read
Post::all();
Post::find(1);
Post::findOrFail(1); // throws 404 if missing
Post::where('published', true)->get();
Post::where('views', '>', 100)->orderBy('views', 'desc')->take(5)->get();
Post::first();
Post::count();
// Write
$post = Post::create(['title' => 'Hi', 'body' => '...']); // needs $fillable
$post->update(['title' => 'Updated']);
$post->delete();
// Relations
class Post extends Model
{
protected $fillable = ['title', 'body'];
public function user() { return $this->belongsTo(User::class); }
public function comments() { return $this->hasMany(Comment::class); }
}
$post->comments; // lazy load
Post::with('comments')->get(); // eager load, avoids the N+1 problem
create()andupdate()only assign fields listed in$fillable. A silently missing column is almost always a missing$fillableentry.- Model
Postmaps to tablepostsby convention (plural, snake_case).
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use SoftDeletes; // needs a nullable deleted_at column, see Migrations
}
$post->delete(); // sets deleted_at, keeps the row
$post->trashed(); // true if soft deleted
$post->restore(); // clears deleted_at
$post->forceDelete(); // actually removes the row
Post::withTrashed()->get(); // include soft-deleted rows
Post::onlyTrashed()->get(); // only soft-deleted rows
SoftDeletesmarks a row deleted instead of removing it. Every normal query excludes trashed rows automatically, so nothing else in the app has to filter them out.- Add the column with
$table->softDeletes()in a migration.
Clean Models
class Order extends Model
{
public function isPaid(): bool
{
return $this->status === 'paid';
}
}
if ($order->isPaid()) {
// ...
}
- Logic used in more than one place, a controller, a job, a view, belongs on the model as a method, not copy-pasted at every call site.
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\Hash;
class User extends Model
{
protected function fullName(): Attribute
{
return Attribute::make(
get: fn () => "{$this->first_name} {$this->last_name}",
);
}
protected function password(): Attribute
{
return Attribute::make(
set: fn (string $value) => Hash::make($value),
);
}
}
$user->full_name; // read like a column, no parentheses
$user->password = 'secret'; // hashed automatically on assignment
- Access an accessor as
$user->full_name, not$user->fullName(). A caller shouldn’t need to know whether an attribute is a real column or computed; both should look the same. - The method is camelCase (
fullName); Eloquent exposes it as the snake_case attribute (full_name).
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
class Post extends Model
{
#[Scope]
protected function published(Builder $query): void
{
$query->where('published', true);
}
}
Post::published()->latest()->get();
Post::published()->where('user_id', $userId)->get();
- A
whereclause repeated across several controllers is a sign it belongs in a scope instead, chainable like any other query method.
{{-- Avoid --}}
@php
$total = 0;
foreach ($order->items as $item) {
$total += $item->price * $item->quantity;
}
@endphp
<p>Total: {{ $total }}</p>
{{-- Prefer --}}
<p>Total: {{ $order->total }}</p>
@php/@endphpin a view is usually a sign the logic belongs on the model instead, as an accessor, a scope, or a dedicated method.
// app/Services/OrderService.php
class OrderService
{
public function checkout(User $user, array $items): Order
{
// create the order, charge payment, send the confirmation email...
}
}
// app/Http/Controllers/OrderController.php
class OrderController extends Controller
{
public function __construct(private OrderService $orderService) {}
public function store(Request $request)
{
$order = $this->orderService->checkout($request->user(), $request->validated());
return redirect()->route('orders.show', $order);
}
}
- When a controller method starts coordinating several models, a payment, a notification, move that logic into a Service class injected through the constructor. The controller stays a thin coordinator: receive the request, delegate, respond.
- Laravel doesn’t ship a
make:servicecommand; a service is just a plain class, conventionally underapp/Services/.
Migrations
// database/migrations/xxxx_create_posts_table.php
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('body');
$table->boolean('published')->default(false);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
- Never edit a migration that already ran in production; add a new one instead.
- Locally,
php artisan migrate:freshis the fast way to redo everything.
Blade Templates
{{-- resources/views/posts/index.blade.php --}}
@extends('layouts.app')
@section('content')
<h1>{{ $title }}</h1> {{-- escaped output --}}
{!! $trustedHtml !!} {{-- raw output, only for content you trust --}}
@if ($posts->isEmpty())
<p>No posts yet.</p>
@else
@foreach ($posts as $post)
<a href="{{ route('posts.show', $post) }}">{{ $post->title }}</a>
@endforeach
@endif
@auth
<p>Hello, {{ auth()->user()->name }}</p>
@endauth
<form method="POST" action="{{ route('posts.store') }}">
@csrf
<input name="title" value="{{ old('title') }}">
@error('title') <span>{{ $message }}</span> @enderror
<button>Save</button>
</form>
@endsection
- Every non-GET form needs
@csrf, otherwise the request is rejected with 419. - Use
@method('PUT')/@method('DELETE')for verbs HTML forms can’t send.
Validation
$request->validate([
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', 'confirmed', 'min:8'],
'age' => ['nullable', 'integer', 'between:18,120'],
]);
php artisan make:request StorePostRequest
- On failure, Laravel redirects back automatically with errors and old
input; no
try/catchneeded. - For bigger rule sets, use a Form Request, generated above.
Vite and Assets
{{-- In your layout's <head> --}}
@vite(['resources/css/app.css', 'resources/js/app.js'])
npm run dev # development: hot reload, must stay running
npm run build # production: writes public/build/ + manifest.json
- In development,
npm run devruns alongsidephp artisan serve.composer run devstarts both, plus the queue worker, in one terminal. - In production, or whenever the dev server isn’t running, you must have
run
npm run buildat least once.
Testing
php artisan make:test PostTest # feature test, in tests/Feature
php artisan make:test PostTest --unit # unit test, in tests/Unit
php artisan test # run the whole suite
php artisan test --filter=PostTest # run a single test class
use Illuminate\Foundation\Testing\RefreshDatabase;
class PostTest extends TestCase
{
use RefreshDatabase;
public function test_post_can_be_created(): void
{
$response = $this->post('/posts', ['title' => 'Hi', 'body' => '...']);
$response->assertRedirect();
$this->assertDatabaseHas('posts', ['title' => 'Hi']);
}
}
RefreshDatabaseresets your database. It migrates the schema before the suite runs and wraps each test in a transaction that’s rolled back afterward, so tests don’t see each other’s data.- A fresh
phpunit.xmlpoints tests atDB_CONNECTION=sqlite,DB_DATABASE=:memory:, an isolated database that’s thrown away after each run. If those lines are missing, commented out, or edited to point at your real database, running tests will wipe your local data. - Check which database tests actually use with
phpunit.xml, or runphp artisan config:show database.default(afterphp artisan config:clear) inside thetestingenvironment. - Unit tests (
tests/Unit) don’t boot the framework and can’t touch the database at all; feature tests (tests/Feature) do, and are where most of your tests should live.
Jobs, Commands, and Scheduling
php artisan make:job ProcessPodcast
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
class ProcessPodcast implements ShouldQueue
{
use Queueable;
public function __construct(public Podcast $podcast) {}
public function handle(): void
{
// transcode the podcast, generate a thumbnail...
}
}
ProcessPodcast::dispatch($podcast); // pushed to the queue
ProcessPodcast::dispatchSync($podcast); // runs immediately, no queue
- A job is work that shouldn’t block the request: sending an email,
processing a file, calling a slow API.
dispatch()queues it; a worker (php artisan queue:work) picks it up and runshandle(). - Needs a queue connection in
.env(QUEUE_CONNECTION=database,redis, etc).sync, the default in a fresh install, runs jobs immediately with no real queueing, useful for local development.
php artisan make:command SendEmails
namespace App\Console\Commands;
use Illuminate\Console\Command;
class SendEmails extends Command
{
protected $signature = 'emails:send {user}';
protected $description = 'Send a batch of emails to a user';
public function handle(): void
{
$this->info("Sending emails to user #{$this->argument('user')}...");
}
}
php artisan emails:send 1
- A command is anything you want to run from the CLI: a one-off
script, a maintenance task, something you’ll also want to schedule.
Commands in
app/Console/Commandsare discovered automatically.
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('emails:send 1')->daily();
Schedule::job(new ProcessPodcast($podcast))->everyFiveMinutes();
Schedule::call(fn () => Log::info('Heartbeat'))->everyMinute();
- The scheduler replaces per-task cron entries with code, defined in
routes/console.php. The server needs only one cron line, running every minute:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
- Locally, skip the cron entry and run
php artisan schedule:workinstead; it loops in the foreground and fires due tasks every minute. php artisan schedule:listshows every scheduled task and its next run time.
Common Errors
No application encryption key has been specified—APP_KEYempty or.envmissing. Fix:cp .env.example .env && php artisan key:generate.- Blank page or a generic 500 — the real error is hidden. Set
APP_DEBUG=truein.env, or readstorage/logs/laravel.log. SQLSTATE[HY000] [2002] Connection refused— the database isn’t running, orDB_HOST/DB_PORTin.envare wrong.SQLSTATE[HY000] [1045] Access denied for user— wrongDB_USERNAME/DB_PASSWORD. Fix them, thenphp artisan config:clear.SQLSTATE[42S02] Base table or view not found— migrations never ran. Fix:php artisan migrate.Unable to open database file(SQLite) — the file doesn’t exist. Fix:touch database/database.sqlite.Vite manifest not found at public/build/manifest.json— assets were never built and the dev server is off. Fix:npm run build, or runnpm run dev.- Page loads but CSS/JS is missing —
npm run devstopped. Restart it, or runnpm run build. - 419 Page Expired — missing
@csrfor an expired session. Add@csrfto the form, or clear cookies. - 404 on a route you just added — cached routes, or the wrong verb.
Run
php artisan route:clear, then checkphp artisan route:list. Target class [FooController] does not exist— wrong namespace or a typo in the route. Import the controller and use[FooController::class, 'method'].Class "App\Models\Post" not found— autoloader out of date. Fix:composer dump-autoload..envchange has no effect — config is cached. Fix:php artisan config:clear(oroptimize:clear).The stream or file .../laravel.log could not be opened— no write permission. Fix:chmod -R 775 storage bootstrap/cache.- Uploaded images 404 in
/storage/...— the symlink is missing. Fix:php artisan storage:link. Add [title] to fillable property— mass assignment blocked. Add the column to$fillableon the model.Attempt to read property on null— a query returnednull. UsefindOrFail(), or guard with@if/?->.composer installfails onext-...— a PHP extension is missing. Install it (e.g.php-mbstring,php-xml,php-curl).- Using Sail,
Connection refusedon127.0.0.1— the command ran on the host, orDB_HOSTis wrong. Prefix withsail; setDB_HOST=mysql.
The Debugging Order
tail -f storage/logs/laravel.log # 1. read the actual error
php artisan optimize:clear # 2. clear every cache
php artisan migrate:status # 3. is the schema up to date?
php artisan route:list # 4. does the route exist as you think?
php artisan about # 5. env, DB connection, cache state
- Most beginner 500s are one of three things: no
APP_KEY, no database connection, or migrations that never ran.