Feature-Based Structure, Modularity, and Bootstrap Design

Compare traditional layer-based folder organization with feature-based (module-based) organization, understand the modularity and dependency-boundary benefits of grouping by feature, and learn where application configuration and bootstrap wiring should live.

As a PHP application grows past a handful of screens, how you lay out folders stops being cosmetic. A structure organized by technical layer scales badly with feature count; a structure organized by feature scales badly only with feature complexity — which is usually the tradeoff you actually want.

Layer-based structure

The traditional MVC-style layout groups files by technical role: every controller lives in app/Controllers, every model in app/Models, every view in app/Views, every service in app/Services. This is the default many frameworks scaffold for you, and it works fine for small applications because there is little to get lost in.

The problem appears as the app grows: to understand or change one feature — say, 'order refunds' — you must open four or five different folders and hunt for the files whose names happen to relate to refunds, scattered among dozens of unrelated controllers, models, and services for every other feature. Nothing in the folder structure tells you which files belong together; only naming conventions do, and those drift.

• app/Controllers/OrderController.php, UserController.php, ProductController.php, ... • app/Models/Order.php, User.php, Product.php, ... • app/Services/OrderService.php, UserService.php, ProductService.php, ... • One folder per technical layer, features smeared across all of them

Feature-based structure

A feature-based (or module-based) structure inverts this: the top-level folders are features — Orders, Users, Catalog — and inside each one you find that feature's own controller, service, repository, and view files together. The technical layers still exist, they are just nested one level deeper, inside the feature that owns them.

This makes the file layout answer the question 'what does this application do?' before it answers 'what technical pattern does it use?' — a new developer can open src/Orders/ and see essentially the whole slice of the system responsible for orders, without needing to know it also touches four other top-level folders.

• src/Orders/OrderController.php, OrderService.php, OrderRepository.php, Order.php • src/Users/UserController.php, UserService.php, UserRepository.php, User.php • src/Catalog/ProductController.php, ProductService.php, ProductRepository.php, Product.php • Each feature folder is close to a self-contained vertical slice of the app

Modularity and dependency boundaries

Grouping by feature only pays off if features are also kept loosely coupled from each other — otherwise you have just renamed the folders without changing the dependency graph. A useful discipline is: a feature may depend on another feature only through a small, explicit interface it exposes (for example, Orders may call a UserLookup interface that Users implements), never by reaching directly into another feature's internal classes, repositories, or database tables.

This dependency boundary is what makes the structure genuinely modular rather than just tidy: it becomes possible to reason about, test, and even extract a single feature (say, splitting Catalog into its own service) without tracing references scattered across the whole codebase. Shared code that many features genuinely need — a Money value object, a base Repository class, framework glue — belongs in a clearly separate Shared or Common area, not inside any one feature folder.

Configuration and bootstrap design

Bootstrap is the code that runs once, at application startup, to wire everything together: reading configuration, building the service container, registering routes, and creating the objects the rest of the request lifecycle will use. This code is deliberately kept separate from feature code — a feature class should never read environment variables or construct its own dependencies; it should simply declare what it needs (usually via constructor parameters) and let the bootstrap process supply it.

A common, effective layout keeps a single entry point (public/index.php) that does almost nothing except load a bootstrap file; the bootstrap file loads configuration (often from .env plus PHP config arrays), builds the service container, and registers each feature module's routes and bindings; and each feature exposes its own small 'routes' or 'provider' file that the bootstrap process pulls in, so adding a new feature means adding one registration line, not editing scattered global files.

A feature-based tree next to its bootstrap entry point

Each feature owns its own routes.php so registering a new feature is a one-line addition in bootstrap, not an edit to a giant shared routes file. Shared/ holds code with no single feature owner — it should stay small and genuinely cross-cutting. public/index.php is intentionally thin; all real wiring happens in bootstrap/app.php, which is not part of any feature.

src/
  Orders/
    OrderController.php
    OrderService.php
    OrderRepository.php
    Order.php
    routes.php
  Users/
    UserController.php
    UserService.php
    UserRepository.php
    User.php
    routes.php
  Shared/
    Money.php
    Repository.php
config/
  app.php
  database.php
bootstrap/
  app.php        // builds the container, loads config, registers each feature's routes.php
public/
  index.php      // require bootstrap/app.php; then dispatch the request

Code Examples

This bootstrap file is the only place that knows about configuration files, the container, and the full list of active features. It never contains business logic — it only loads config, builds shared infrastructure (the container, a PDO connection), and delegates route registration to each feature's own routes.php, which is the small seam feature-based structure relies on.

$registerRoutes = require __DIR__ . "/../src/{$feature}/routes.php"; keeps feature registration data-driven — adding Catalog to the array is the entire integration step.

This file lives inside the Orders feature folder and is the only thing outside that folder that bootstrap needs to know about. Everything internal to placing an order — the service, the repository, the command object — stays private to src/Orders, which is exactly the dependency boundary that keeps features loosely coupled.

Common mistake: Having a UserController inside the Orders feature reach directly into src/Users/UserRepository.php to fetch user data, instead of going through a small interface Users exposes — that silently recreates a tangled layer-based dependency graph underneath a feature-shaped folder structure.

<?php
// bootstrap/app.php

require __DIR__ . '/../vendor/autoload.php';

$config = [
    'app' => require __DIR__ . '/../config/app.php',
    'database' => require __DIR__ . '/../config/database.php',
];

$container = new Container();
$container->set('config', fn () => $config);
$container->set(PDO::class, fn ($c) => new PDO(
    $c->get('config')['database']['dsn'],
    $c->get('config')['database']['user'],
    $c->get('config')['database']['pass'],
));

// Each feature registers its own bindings and routes — bootstrap never
// hardcodes feature internals, it only knows the registration entry point.
$router = new Router();
foreach (['Orders', 'Users', 'Catalog'] as $feature) {
    $registerRoutes = require __DIR__ . "/../src/{$feature}/routes.php";
    $registerRoutes($router, $container);
}

return ['container' => $container, 'router' => $router];
<?php
// src/Orders/routes.php

return function (Router $router, Container $c): void {
    $router->post('/orders', function () use ($c) {
        $service = new PlaceOrderService(
            $c->get(PDO::class),
            $c->get(StockReservation::class),
            $c->get(PaymentGateway::class),
            new OrderRepository($c->get(PDO::class)),
            $c->get(OrderConfirmationMailer::class),
        );
        return $service->place(PlaceOrderCommand::fromRequest($_POST));
    });
};

Layer-based vs feature-based, side by side

Layer-based structure scatters a single feature across every top-level folder; feature-based structure keeps it together and pushes technical layering one level down.

Layer-based:                Feature-based:
Controllers/                 Orders/
  Order, User, Product          OrderController
Models/                        OrderService
  Order, User, Product          OrderRepository
Services/                    Users/
  Order, User, Product          UserController
                                UserService
(one 'Order' feature =          UserRepository
 3 files, 3 folders)
                              (one 'Order' feature =
                               all files, 1 folder)

Important Rules

Group by feature first, technical layer second — a feature folder should let a developer see nearly the whole slice of behavior it owns.

A feature may depend on another feature only through a small interface that feature exposes, never by reaching into its internals directly.

Keep a Shared/Common area for genuinely cross-cutting code, and keep it small — most code should have exactly one feature owner.

Bootstrap code (config loading, container setup, route registration) stays outside every feature folder — features declare dependencies, bootstrap supplies them.

Adding a new feature should mean adding a new folder plus one registration line in bootstrap, not editing scattered global files.

Where this shows up

Frameworks like Symfony (bundles) and Laravel (modules via packages, or manually organized feature folders) both support feature-based organization once an app grows past its starter scaffold. Domain-Driven Design's 'bounded context' is essentially the same idea at a larger scale — each context owns its own models and exposes a deliberately narrow interface to the rest of the system.

src/Orders/OrderController.php calls new UserRepository()->find($id) directly to look up a customer's name.

src/Orders/OrderController.php depends on a small UserLookup interface, implemented by the Users feature and injected via the container.

Reaching directly into another feature's repository recreates tight coupling between features — the exact problem feature-based structure is meant to prevent — and makes it impossible to change or extract either feature independently.

Reading $_ENV['DB_HOST'] and constructing a new PDO(...) inside a feature's service class.

The service declares a PDO (or a repository) as a constructor dependency; bootstrap reads configuration and builds the PDO connection once.

Letting feature code read configuration or build its own infrastructure objects duplicates wiring logic across the app and defeats the purpose of a single, central bootstrap process.

[Multiple Choice] What is the main advantage of feature-based structure over layer-based structure as an application grows? A. It requires fewer PHP files overall B. It keeps the files for one feature together, instead of scattering them across every technical-layer folder C. It removes the need for a service layer entirely D. It makes the database schema simpler

B. It keeps the files for one feature together, instead of scattering them across every technical-layer folder — Feature-based structure groups a feature's controller, service, repository, and model together so that understanding or changing one feature does not require hunting through every top-level layer folder.

[True / False] Bootstrap/wiring code (loading configuration, building the container, registering routes) should live inside one of the feature folders that uses it most. A. True B. False

B. False — False. Bootstrap code should stay outside every feature so no single feature owns application-wide wiring; features declare what they need and bootstrap supplies it centrally.

You have a layer-based app with app/Controllers/ProductController.php, app/Models/Product.php, and app/Services/ProductService.php, plus equivalent files for Order and User. Describe the new feature-based folder layout, and name one rule you would enforce so the Orders feature cannot directly instantiate a ProductRepository from the Catalog feature.

src/Catalog/{ProductController,ProductService,ProductRepository,Product}.php, src/Orders/{OrderController,OrderService,OrderRepository,Order}.php, src/Users/{...}. Rule: Orders may depend only on a ProductLookup interface exposed by Catalog (e.g. findById(): ProductSummary), injected through the container — never on Catalog\ProductRepository directly.

Think about what small interface Catalog could expose instead of Orders reaching into Catalog's repository.