Project structure — PSR-4 namespace, src/, tests/, public/
Concept
The directory structure of a framework is its most public API — every developer who picks it up will navigate the folder tree before they read a single line of code. A well-designed structure communicates the architecture visually. When you open a Laravel project and see app/Http/Controllers, app/Models, config/, database/migrations/, routes/, you instantly know where things live without reading docs. That communication is intentional design.
PSR-4 is the standard that connects directory structure to PHP namespaces. Before PSR-4, projects used require or include to manually load files, or used PSR-0 which required mirroring the full namespace path including underscores as directories. PSR-4 is simpler: you define a base namespace prefix and a base directory, and Composer maps Vendor\Package\SubPath\ClassName to base_dir/SubPath/ClassName.php. The key rule: the directory structure from the base directory must match the namespace structure from the base prefix.
Our framework will use two PSR-4 roots: Lumen\ mapped to src/ for the framework code, and App\ mapped to app/ for user application code. This mirrors Laravel exactly. Tests get Tests\ mapped to tests/. This separation means framework internals and application code can evolve independently — you could in theory swap our framework for another without touching anything in app/.
The public/ directory is the only one exposed to the web server. Nginx or Apache should be configured so that all requests hit public/index.php. Everything else — source code, config, storage, vendor — lives outside the web root. This is a security boundary: even if a misconfigured server sends a raw file response, it can never serve your .env file or your framework source.
The storage/ directory needs to be writable by the web server process. A common deployment mistake is running chmod 777 storage/ in production, which is a security hole. The correct approach is to ensure the web server user (e.g., www-data) owns the directory: chown -R www-data:www-data storage/ && chmod -R 755 storage/.
Code Example
<?php
declare(strict_types=1);
/**
* Recommended project structure for the Lumen framework skeleton:
*
* lumen-app/
* ├── app/
* │ ├── Http/
* │ │ ├── Controllers/
* │ │ └── Middleware/
* │ └── Models/
* ├── config/
* │ ├── app.php
* │ └── database.php
* ├── database/
* │ └── migrations/
* ├── public/
* │ └── index.php ← ONLY this is web-accessible
* ├── routes/
* │ ├── web.php
* │ └── api.php
* ├── src/ ← Framework source (Lumen\ namespace)
* │ ├── Foundation/
* │ ├── Container/
* │ ├── Routing/
* │ ├── Http/
* │ ├── Pipeline/
* │ └── Database/
* ├── storage/
* │ ├── cache/
* │ └── logs/
* ├── tests/
* │ ├── Unit/
* │ └── Feature/
* ├── vendor/ ← Composer-managed, never touch
* ├── .env
* ├── .env.example
* ├── composer.json
* └── phpunit.xml
*/
// composer.json — full version with all required fields
/*
{
"name": "lumen/app",
"description": "Application built on the Lumen framework",
"type": "project",
"license": "MIT",
"require": {
"php": "^8.2"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"Lumen\\": "src/",
"App\\": "app/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"test": "vendor/bin/phpunit",
"test:coverage": "vendor/bin/phpunit --coverage-html=coverage"
},
"config": {
"optimize-autoloader": true,
"sort-packages": true
}
}
*/
// Verify PSR-4 mapping is working — create this as src/Foundation/Application.php
// Namespace: Lumen\Foundation
// Class: Application
// File path: src/Foundation/Application.php
// All three must align for Composer autoloading to work.
namespace Lumen\Foundation;
/**
* A simple path-resolution helper to verify the PSR-4 setup.
* Run: php -r "require 'vendor/autoload.php'; var_dump(class_exists(\Lumen\Foundation\Application::class));"
* Expected output: bool(true)
*/
class Application
{
public function __construct(
private readonly string $basePath
) {}
public function basePath(string $path = ''): string
{
if ($path === '') {
return $this->basePath;
}
return $this->basePath . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR);
}
/**
* Confirm the autoloader is working and paths resolve correctly.
* In production, remove this diagnostic method.
*/
public function diagnose(): array
{
return [
'base_path' => $this->basePath(),
'config_path' => $this->basePath('config'),
'storage_path' => $this->basePath('storage'),
'public_path' => $this->basePath('public'),
'php_version' => PHP_VERSION,
];
}
}Interview Q&A
Q: What is the difference between PSR-0 and PSR-4 autoloading?
PSR-0 (now deprecated) required the full namespace, including underscores, to map to the directory tree. A class Vendor_Package_Foo would live at Vendor/Package/Foo.php — underscores were directory separators. PSR-4 eliminated this legacy convention and simply requires that the portion of the namespace after the registered prefix maps to the directory structure. Lumen\Routing\Router with prefix Lumen\ mapped to src/ resolves to src/Routing/Router.php. PSR-4 is cleaner, shorter, and what Composer has used as the default since Composer 1.2.
Q: Why should vendor/ never be committed to version control?
The vendor/ directory is a reproducible artifact — given composer.json and composer.lock, any machine with Composer installed can reconstruct it exactly. Committing it adds megabytes of churn to every PR diff, makes git clone slow, and creates merge conflicts on lock file changes. The only legitimate exception is a deployment system that cannot run Composer (a shared host with no shell access) — in that case you commit vendor/ as a last resort. In all other cases, composer install --no-dev in your CI/CD pipeline is the correct approach. Always commit composer.lock though — it pins exact dependency versions for reproducibility.
Q: Why is public/ the only web-accessible directory?
This is a web server configuration security boundary. If your src/ or .env files were inside the web root, a misconfigured Nginx try_files directive or a server with directory listing enabled could expose your source code, database credentials, or secret keys to the internet. Keeping only public/ in the web root means the worst-case misconfiguration exposes only your compiled CSS, JavaScript, and images — assets that are public by design. Laravel enforces this too: the Nginx config snippet in Laravel's documentation explicitly sets root to public/ and passes everything else to index.php.