Project Structure & Tooling
Directories, Composer, Artisan, .env configuration and Vite.
A fresh Laravel project has a lot of folders, but each one has a single, predictable job. Knowing where things live, and which tool generates them, is the difference between fighting the framework and moving fast. This lesson tours the directory layout, then covers the four tools you'll use every day: Composer, Artisan, .env configuration and Vite.
The directory map
| Folder | What lives there | Created by |
|---|---|---|
app/ |
Controllers, models, providers, jobs, policies, your services | php artisan make:* |
bootstrap/ |
app.php (configure the app), cache/ (compiled config/routes) |
Framework |
config/ |
One PHP file per subsystem, returning an array | You + packages |
database/ |
Migrations, factories, seeders | make:migration, make:factory, make:seeder |
public/ |
The only web root: index.php, compiled assets, storage symlink |
You / Vite |
resources/ |
Blade views, uncompiled CSS/JS, language files | You |
routes/ |
web.php, console.php, optional api.php / channels.php |
You |
storage/ |
Logs, file cache, sessions, compiled views, uploaded files | Runtime |
tests/ |
Feature/ (HTTP-level) and Unit/ tests |
make:test |
vendor/ |
Composer dependencies, Laravel itself included | composer install |
The web server's document root must be public/, never the project root. Pointing it at the root would expose .env (database passwords, APP_KEY) to anyone who requests /.env.
This project as a worked example
The app serving this tutorial is a CMS plus the Projects & Tasks example that every lesson uses. Between them they fill in most of the folders a real Laravel app grows:
app/
├── Enums/TaskPriority.php # Low / Medium / High, used as a model cast
├── Events/TaskCompleted.php # dispatched by Task::markComplete()
├── Filament/Resources/ # Admin panel CRUD screens (Filament 4)
├── Http/
│ ├── Controllers/ # PageController, BlogController, TutorialController
│ │ └── Api/ # ProjectController, TaskController, QueryCountController
│ ├── Middleware/AddQueryCountHeader.php
│ ├── Requests/StoreTaskRequest.php
│ └── Resources/ # ProjectResource, TaskResource, TagResource
├── Listeners/LogTaskCompletion.php # queued listener (ShouldQueue)
├── Models/ # Project, Task, Tag, User + CMS: Page, Post, BlogType, Media
├── Policies/TaskPolicy.php
├── Providers/ # AppServiceProvider + Filament AdminPanelProvider
└── Services/ # SiteConfigService, TutorialRepository
database/
├── factories/ # ProjectFactory, TaskFactory, TagFactory, UserFactory
├── migrations/ # …create_projects / tasks / tags / tag_task tables
└── seeders/TutorialDemoSeeder.php # fixed, idempotent demo data
routes/
├── web.php # CMS pages + /tutorials
└── api.php # /api/demo/* (Projects & Tasks JSON API)
resources/
├── tutorials/laravel/*.md # ← the Markdown for this very page
└── views/
├── blocks/ # One Blade partial per CMS block type
├── layouts/app.blade.php
└── tutorials/ # index, track, show
tests/Feature/Demo/ # API, policy, event and seeder tests
docker/ # local dev: nginx config + container entrypoint
terraform/ # legacy, from an ended AWS trial (unused)
Nothing forces app/Services or app/Enums to exist. make:policy, make:event and the other generators create their folders the first time you use them. Laravel only cares about the PSR-4 namespace: App\Services\TutorialRepository lives at app/Services/TutorialRepository.php, so Composer's autoloader can find it.
Composer: PHP's package manager
composer.json declares dependencies, and composer.lock pins the exact versions that were installed. Commit both.
composer.json (excerpt)
{
"require": {
"php": "^8.3",
"laravel/framework": "^13.0",
"filament/filament": "^4.0",
"league/flysystem-aws-s3-v3": "^3.32"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
}
}
composer install # install exactly what's in composer.lock (CI / deploy)
composer require spatie/laravel-permission # add a package + update the lock file
composer update laravel/framework # bump one package within its constraint
composer dump-autoload # rebuild the class map after moving files
composer install --no-dev --optimize-autoloader # production
Worth knowing: ^13.0 means "13.x, anything below 14.0" (semver caret). composer install reads the lock file and gives everyone identical versions. composer update ignores the lock, resolves fresh versions and rewrites it. Deployments should only ever run install.
Artisan: the command line
artisan is a PHP script at the project root that boots the framework in console mode. Most of your day-to-day work starts here.
Generators
These commands create the kinds of files the Projects & Tasks example is made of:
php artisan make:model Task -mf # app/Models/Task.php + migration + TaskFactory
php artisan make:enum Enums/TaskPriority --string # app/Enums/TaskPriority.php
php artisan make:controller Api/TaskController # app/Http/Controllers/Api/TaskController.php
php artisan make:request StoreTaskRequest # app/Http/Requests/StoreTaskRequest.php
php artisan make:resource TaskResource # app/Http/Resources/TaskResource.php
php artisan make:middleware AddQueryCountHeader # app/Http/Middleware/AddQueryCountHeader.php
php artisan make:policy TaskPolicy --model=Task # app/Policies/TaskPolicy.php
php artisan make:event TaskCompleted # app/Events/TaskCompleted.php
php artisan make:listener LogTaskCompletion --event=TaskCompleted --queued
php artisan make:seeder TutorialDemoSeeder
php artisan make:test Demo/DemoApiTest # tests/Feature/Demo/DemoApiTest.php
Each generator writes a file from a stub in the correct folder, with the right namespace. You can publish and customise the stubs with php artisan stub:publish.
Inspecting and running
php artisan route:list --path=api/demo # every route, its middleware and action
php artisan about # versions, drivers, cache status
php artisan migrate # run outstanding migrations
php artisan tinker # REPL with the app booted
php artisan queue:work # process queued jobs
php artisan serve # local dev server on :8000
tinker is the fastest way to poke at your models. This is real output from this app's seeded database:
> Project::count()
= 3
> Project::firstWhere('slug', 'website-relaunch')->tasks()->open()->pluck('title')
= Illuminate\Support\Collection {#6123
all: [
"Build pricing calculator in Vue",
"Migrate blog posts to new CMS",
"Fix mobile nav overlapping logo",
"Set up 301 redirects for old URLs",
"Add Open Graph images",
"Load test the contact form API",
],
}
open() is a query scope defined on app/Models/Task.php. The Eloquent lesson shows it.
Writing your own command
Illustrative, not in this app. A command that cleans up old completed tasks:
// php artisan make:command PruneCompletedTasks
class PruneCompletedTasks extends Command
{
protected $signature = 'tasks:prune {--days=30 : Delete tasks completed more than N days ago}';
protected $description = 'Delete old completed tasks';
public function handle(): int
{
$count = Task::completed()
->where('completed_at', '<', now()->subDays((int) $this->option('days')))
->delete();
$this->info("Pruned {$count} tasks.");
return self::SUCCESS;
}
}
Schedule it in routes/console.php and add a single cron entry, * * * * * php artisan schedule:run, on the server:
use Illuminate\Support\Facades\Schedule;
Schedule::command('tasks:prune --days=60')->dailyAt('02:00');
Configuration: .env and config/
Configuration has two layers, and mixing them up is a common bug:
.envholds environment-specific values and secrets. It's never committed, and each server has its own.config/*.phpholds the shape of the configuration. Each file returns an array and reads.envviaenv()with a sensible default. These files are committed.
# .env (local)
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:8080
DB_CONNECTION=mysql
DB_HOST=db
DB_DATABASE=laravel_cms
CACHE_STORE=file
// config/cms.php: committed, documents what's configurable
return [
'site_id' => env('SITE_ID'),
'config_api_url' => env('CMS_CONFIG_API_URL'),
];
// Application code reads config, never env()
$apiBase = config('cms.config_api_url');
This project uses exactly that pattern. Production runs on a self-hosted IONOS VPS (demo1.matt-9.com), and its .env lives only on the server. The deploy script uploads the new code but keeps the server's .env, so production settings such as QUEUE_CONNECTION=sync, CACHE_STORE=file, SESSION_DRIVER=file and the database credentials never pass through Git. The local Docker Compose stack sets its own values as container environment variables, and env() reads those just the same.
config/tutorials.php is a config file that holds data, not secrets: the list of tracks, levels and lessons for this tutorial. It's ordinary PHP, so it gets cached by config:cache along with everything else.
Environment-aware code
if (app()->environment('local')) {
// e.g. register a debug toolbar
}
if (app()->isProduction()) {
URL::forceScheme('https');
}
Front-end assets with Vite
Laravel ships with Vite for compiling CSS and JavaScript. The Blade @vite directive outputs the right tags. In development they point at the Vite dev server with hot module replacement; in production they point at hashed files in public/build:
// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true }),
tailwindcss(),
],
});
<head>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
npm install
npm run dev # dev server with HMR
npm run build # production build → public/build/manifest.json
The Vue lessons cover how Vite compiles .vue single-file components inside a Laravel app.
Laravel Boost is installed here as a dev dependency (composer require laravel/boost --dev, then php artisan boost:install). It gives AI coding agents an MCP server with version-matched docs search, the real database schema and the latest errors. It also adds Laravel guidelines to CLAUDE.md, next to the project's own notes in that file.
A typical first hour
Illustrative. Starting a Projects & Tasks app from scratch:
composer create-project laravel/laravel projects-app
cd projects-app
cp .env.example .env && php artisan key:generate
php artisan migrate # SQLite by default in new apps
php artisan make:model Project -mf
php artisan make:model Task -mf
php artisan install:api # creates routes/api.php
npm install && npm run dev
php artisan serve
In this repo, the local equivalent is the Docker Compose stack, whose entrypoint runs migrate --seed on every start. Production is deployed to the IONOS VPS by a Python script, which:
- Uploads the code as a tarball over SFTP.
- Backs up the current code and database.
- Runs
composer install --no-dev, keeping the server's.env. - Runs
php artisan migrate --forceand theTutorialDemoSeeder. - Rebuilds the config, route and view caches.
Because the seeder is idempotent, running it on every deploy is safe. This is the seeded data, served live from the production database:
From here the Basic lessons walk through the example's routes and controllers, its views and its database layer.
Key takeaways
app/is your code (PSR-4 autoloaded),public/is the only web root, andvendor/,storage/andbootstrap/cacheare generated.- Composer manages PHP dependencies. Commit
composer.lockand deploy withcomposer install. - Artisan generates boilerplate in the right place, inspects the app and runs migrations, queues and the scheduler.
- Secrets live in
.envor the real environment. Code readsconfig(), neverenv(). - Vite compiles front-end assets, and
@vitelinks them correctly in dev and production.