How to Structure a Modern PHP Project Framework Neutral Guide
A framework-neutral PHP project structure separates public entry points from application source using PSR-4 and Composer conventions—here is the durable layout.
How to Structure a Modern PHP Project Framework Neutral Guide
Separate public entry points from application source. That is the one durable rule. A public/ directory holds the front controller and any asset a browser can request directly. An src/ folder holds every class that must never be addressable by URL. This boundary is the difference between a project that survives a hosting migration and one that leaks source code when someone forgets to update an Apache directive.
The interviewer slides a laptop across the table and asks you to scaffold a new project, no framework allowed. You open a terminal, type four commands, and five minutes later you have a directory tree that any PHP developer in South Florida would recognise. That tree is not a Laravel or Symfony default. It is a modern PHP project structure framework neutral baseline that survives framework churn because it is built on Composer conventions and PSR-4 autoloading, not on a scaffolding tool that may change its opinion next year.
Framework scaffolding tools give you a different layout. Laravel puts the entry point in public/ but names the source root app/. Symfony uses public/ and src/ but wraps the bootstrap in a kernel class. Slim gives you a single public/index.php and leaves the rest to you. These are popular conventions, not the standard. The standard is the PHP-FIG PSR-4 namespace-to-directory mapping, and Composer is the tool that enforces it. When a framework changes its skeleton in a major release, a project that stuck to the PSR-4 baseline does not need to reorganise its entire codebase.
The Framework-Neutral Directory Layout
Start with Composer. Run composer init and answer the prompts: package name, description, type (choose project for an app, library for a reusable package), and the namespace root. That namespace root is the first segment of every class path in your src/ folder. If your namespace root is Acme\Widgets, then a class at src/Calculator/Pricing.php must declare namespace Acme\Widgets\Calculator;. PSR-4 maps that namespace prefix to the src/ directory inside your composer.json autoload block. Do not override this mapping without a reason that survives a code review.
The resulting tree is small and explicit. At the project root, composer.json defines dependencies and autoloading. composer.lock is the contract: commit it to version control, never delete it as a routine step, and run composer install in every environment that is not your local development machine. The vendor/ folder is generated by Composer and must never be committed. The public/ folder contains exactly one PHP file, index.php, and any static assets. The src/ folder holds your app classes. A config/ folder holds environment-agnostic configuration files. A tests/ folder mirrors src/ and holds PHPUnit test classes.
What Lives in Each Folder
The public/index.php file is the front controller. It requires Composer's autoloader, loads environment variables, and hands the request to your app kernel. This file is the only PHP file the web server must be able to execute directly. Configure your web server to route all requests that do not match a physical file to this script. On Apache, that is a .htaccess with FallbackResource /index.php. On nginx, it is a try_files directive.
The src/ folder has no fixed internal structure beyond what PSR-4 demands, but a durable convention is to group classes by architectural role: Controller/, Service/, Repository/, Model/. These directories map to sub-namespaces. A class in src/Service/PaymentGateway.php declares namespace Acme\Widgets\Service;. This is dependency inversion in practice: your controllers depend on service interfaces, not on concrete implementations, and Composer's autoloader resolves the concrete class from the service container at runtime.
The config/ folder holds files that return arrays. A config/app.php might return database credentials keyed by environment name, pulled from getenv() calls. The tests/ folder uses the same namespace prefix as src/ but a different PSR-4 mapping in composer.json under autoload-dev. A test for src/Service/PaymentGateway.php lives at tests/Service/PaymentGatewayTest.php and extends PHPUnit\Framework\TestCase.
PHP Project Directory Layout
The PHP project directory layout is not an opinion. It is a consequence of two Composer autoloading rules: the vendor/ folder is the package boundary, and the src/ folder is your namespace root. Every file outside these paths either serves the build pipeline or documents the project. A bin/ folder at the project root holds CLI entry points, scripts you invoke with php bin/console. A resources/ folder holds templates, translation files, and database migrations. A storage/ folder holds logs, cache files, and uploaded user content, and it must be writable by the PHP process but never served by the web server.
South Florida hosting environments still run cPane on shared hosts, and the default document root is often public_html, not public/. Rename your public/ folder to public_html/ if the host cannot change the document root, but do not put app source inside it. The layout principle survives the hosting constraint: the front controller stays in the document root, and everything else stays one level above it, outside the web-accessible tree.
A project that follows this layout can move from a local SQLite development database to a production MySQL 8 instance without changing a line of app code, because the database configuration lives in config/ and is loaded from environment variables. The twelve-factor app principle of storing config in the environment is what makes this portability possible. The .env file, parsed by vlucas/phpdotenv, is a development convenience. In production, set real environment variables at the server or container level. Parsing a .env file on every request in production is a performance regression that can cost 5 to 15 milliseconds per request.
Composer Project Template Structure
A Composer project template structure is the composer.json file plus the directory tree that Composer expects to find when it runs install. The template structure is not a framework skeleton. It is the minimal set of files that produce a working app when you clone the repository and run composer install. That minimal set is: composer.json, composer.lock, public/index.php, a config/ folder with at least one file, a src/ folder with at least one class, and a .gitignore that excludes vendor/, .env, and any generated cache files.
The composer.json autoload block is the engine. A PSR-4 entry maps a namespace prefix to a directory. A files entry loads a function file on every request, which is useful for helper functions that do not belong to a class. A classmap entry scans a directory and builds a static map of every class found, which is faster than PSR-4 for directories that contain many classes with no namespace convention. Use classmap for the vendor/ packages that ship without PSR-4 compliance, not for your own source.
Reproducing the Template
The template structure is reproducible because composer.lock pins every dependency to an exact commit hash. When a new developer clones the project, composer install reads the lock file and reconstructs the exact vendor/ folder the previous developer tested. Running composer update is a deliberate act that recalculates the dependency graph and rewrites the lock file. Do it on a feature branch, run the test suite, and merge only when the tests pass. The most common production outage in PHP projects is caused by running composer update during deployment and shipping untested package versions.
PHP Dependency Boundaries
PHP dependency boundaries are the lines in your codebase where one package's classes should not directly instantiate another package's classes. The boundary is enforced by dependency inversion: your app code depends on interfaces you own, and Composer wires those interfaces to concrete implementations from third-party packages. A PaymentGatewayInterface in src/Contract/ is implemented by a StripePaymentGateway in src/Service/ that wraps the Stripe PHP SDK. Your controllers type-hint the interface. If Stripe doubles its API latency or changes its pricing model, you swap the implementation without touching a controller.
The vendor/ folder is the physical boundary. Code inside vendor/ is managed by Composer and must never be edited directly. Code outside vendor/ is yours. The composer.json require block lists the packages your app needs in production. The require-dev block lists packages needed only for development and testing: PHPUnit, Xdebug, a static analysis tool. Running composer install --no-dev in production omits these packages and reduces the vendor/ folder size by 30 to 50 percent, which shrinks the attack surface.
Auditing Dependencies
Dependency count is an audit surface metric, not a performance metric. A project with 80 Composer dependencies and a committed lock file is more secure than a project with 10 dependencies that were copied into the repository by hand. The lock file lets you run composer audit and receive a list of known vulnerabilities in your dependency tree. A manual copy gives you no audit trail. The PHP-FIG standards process exists to reduce the friction at these boundaries: PSR-7 defines HTTP message interfaces so your controllers do not couple to a specific framework's request object, and PSR-15 defines middleware handlers so your authentication logic does not couple to a specific router.
Twelve Factor PHP Project
A twelve factor PHP project treats its runtime configuration as environment variables, its processes as stateless, and its logs as event streams. The methodology is not a PHP-specific invention, but PHP's shared-nothing architecture makes it a natural fit. Every request spawns a new process or thread. No in-memory state persists between requests without an external store like Redis or a database. This constraint is not a limitation; it is the reason PHP apps scale horizontally behind a load balancer without sticky sessions.
Factor three, store config in the environment, is the one that most often fails in South Florida deployments. Developers set configuration in .env files during development and forget to promote those values to real environment variables in production. The result is a .env file on the production server that is parsed on every request, costing 5 to 15 milliseconds of wall time and exposing secrets if the web server misconfiguration ever serves the file directly. The correct pattern: use vlucas/phpdotenv to load .env only when getenv('APP_ENV') returns local or development. In production, the environment variables are set at the container or server level, and the app reads them without touching a file.
Factor four, treat backing services as attached resources, means your database, cache, and queue connections are specified by URL strings in environment variables, not hardcoded in configuration files. A DATABASE_URL environment variable holds a DSN that PDO parses. Changing from a local SQLite database to a production MySQL 8 instance is a one-line change in the environment, not a code change. Factor eleven, treat logs as event streams, means your app writes to stdout and stderr, never to a log file inside the container or the web root. The process manager or container orchestrator collects the streams and routes them to a log aggregator.
The Entry Point and the Front Controller
The entry point is the single public/index.php file that bootstraps the app. It does three things in this exact order: requires the Composer autoloader, loads environment variables if the environment is local, and hands the HTTP request to the app kernel. The kernel is a class in src/ that receives a PSR-7 request object and returns a PSR-7 response object. This pattern is the front controller: one file routes every request, and the web server configuration sends all traffic to it.
Do not put routing logic in index.php. The file should be under 30 lines. Its job is wiring, not decision-making. A router class in src/Router/ reads the request method and URI, matches a route definition, and dispatches to a controller. The controller returns a response, and index.php emits it. This separation means you can swap the router implementation from a hand-rolled one to a FastRoute-based one without touching the entry point.
The most common failure at the entry point is a missing autoloader. A developer moves the public/ folder or copies index.php to a different location and the require __DIR__ . '/../vendor/autoload.php' line breaks. Fix it by computing the path relative to the file's actual location. Use dirname(__DIR__, 2) to walk up two levels from the public/ folder to the project root.
Autoloading and the Namespace Root
Autoloading is the mechanism that loads a class file when the class is first referenced, without a manual require statement. PSR-4 defines the mapping from namespace to directory. When your code references \Acme\Widgets\Service\PaymentGateway, the Composer autoloader looks for src/Service/PaymentGateway.php because composer.json maps the Acme\Widgets\ prefix to the src/ folder. The mapping is case-sensitive on case-sensitive filesystems, which is every Linux production server. A class named PaymentGateway in a file named paymentgateway.php will load on macOS and fail in production.
The namespace root is the first segment of every class path you own. Choose it once and do not change it without a migration plan. A common convention is VendorName\ProjectName\, where VendorName is your organisation or GitHub username and ProjectName is the app. This namespace root appears in composer.json under autoload.psr-4 and in the namespace declaration at the top of every PHP file in src/. If you change it, update both and every use statement in the codebase. A static analysis tool like PHPStan can catch the stragglers.
Composer also supports a files autoloading key for functions that live outside a class. Use it sparingly. A helpers.php file loaded on every request adds a fixed cost to the bootstrap time, under 1 millisecond for a small file, but the cost is paid on every request whether the functions are used or not. If a helper function is only needed in one controller, put it in a class method or a trait instead.
Common Questions
Do I need a framework to use this structure?
No. The structure described here works with plain PHP and Composer alone. A framework adds convenience classes, a router, and a service container, but the directory layout and autoloading rules are the same. Start with this baseline and add Slim for routing, or adopt Laravel later and migrate your src/ classes into the app/ directory with namespace adjustments.
Should I commit the vendor directory?
Never. The vendor folder is generated by Composer and must be listed in .gitignore. Commit composer.lock instead. Any developer or CI pipeline that clones the repository runs composer install to reconstruct the exact vendor folder. Committing vendor bloats the repository and creates merge conflicts on generated files.
Where do I put environment-specific configuration?
In environment variables, not in files inside the repository. Use a .env file for local development only, loaded by vlucas/phpdotenv. In staging and production, set real environment variables at the server or container level. Your config/ directory files read from getenv() and never contain secrets or hostnames.
How do I test a project structured this way?
PHPUnit expects a phpunit.xml configuration file at the project root. Set the test directory to tests/ and configure Composer autoload-dev to map the test namespace to the tests/ directory. Each test class extends PHPUnit\Framework\TestCase and lives in a file named after the class it tests, with a Test suffix. Run vendor/bin/phpunit from the project root.
What is the most common mistake when setting this up?
Putting the app source inside the document root. A developer places src/ inside public/ or puts PHP class files directly in public_html. The result is a project where any visitor can request a source file by URL, which leaks database credentials and business logic. Move everything except the front controller and static assets one directory level above the document root.