Settling the File Structure Debate
Everyone has an opinion about how to organize your files: some swear by grouping by type, others by domain. Today, we're skipping the endless arguing and getting straight to a real-world example that shows why structure matters and how you can pick the right one for the long haul.
This isnβt theory. Itβs survival skills for building projects that are meant to last.
Disclaimer: Different languages and ecosystems, like .NET solutions or Java packages, often use project structure for technical reasons β such as splitting code into independently deployable units (e.g., separating core logic from API endpoints or message consumers). This post focuses purely on project structure for maintainability and clarity, not on deployment or compilation concerns.
Disclaimer 2: The examples shown are written in PHP, as it's the primary language I use in my day-to-day work. That said, the principles and structuring approaches discussed in this post are language-agnostic and apply equally well to any programming language.
File structure expectations
Before we dive into the rabbit hole, letβs address the big fluffy elephant in the room: what even makes a file structure good or bad? The answer might disappoint you: it's subjective.
"But then... why are we here?" you ask. Because even in a world of subjective choices, some principles quietly rise above the noise like old friends you can rely on. And today, we'll meet a few of them.
First, a hard truth: your file structure will never mirror the true behavior of your application at runtime. Why? Because file trees are flat, while your app's object graph is a dynamic, living, breathing web of relationships. Trying to perfectly reflect runtime behavior in folders? That's like trying to catch the wind in a jar.
Instead, we judge structures based on something else: their ability to help us. Mainly, their ability to make the project maintainable over time.
And what makes something maintainable? Iβm glad you asked.
Ease of change
If thereβs one unshakable prophecy in software development, itβs this: Your code will change.
Maybe tomorrow. Maybe in six months. But change is coming faster than your next caffeine crash from the coffee you had this morning. Smart developers accept this upfront and set up their projects to embrace it, not fight it.
In practical terms? Your file structure should make it easy to move things around, tweak behaviors, and add new features without starting a game of 3D Jenga.
Screaming architecture
Picture a blueprint of a house. When you glance at it, what do you see?
If you find yourself saying "two doors, three windows, four walls" youβre missing the bigger picture. You should be thinking "this is a cozy living room, but the kitchen feels cramped."
Your architecture should scream its purpose at you. Not the technical implementation details, but the actual intent and story of your application.
In code, that means you should instantly understand where business rules live, where core actions happen, and which parts of the system serve which role without hunting for clues like a frustrated Inspector Gadget.
Clear-cut knowledge boundaries
Everyoneβs heard of DRYβ"Don't Repeat Yourself." And most developers were taught to think itβs about saving keystrokes.
Hereβs a small revelation: itβs not.
True DRY is about centralizing knowledge. If your system needs to answer a question, there should only be one component responsible for knowing the answer. No debate. No ambiguity.
Examples:
- "Is John authorized to run this action?" β Identity/Access boundary.
- "What is John's preferred language?" β Localization Preferences boundary.
- "John wants to delete his account." β User-Initiated Account Deletion boundary.
In other words: every question or command belongs to exactly one piece of the system. This keeps your system sharp, sane, and safe from the dreaded "who knows this?" syndrome.
Setting up the scene
Take the following real-world file structure:
.
βββ database
βΒ Β βββ factories
βΒ Β βΒ Β βββ AdminFactory.php
βΒ Β βΒ Β βββ EmailVerificationFactory.php
βΒ Β βΒ Β βββ UserFactory.php
βΒ Β βββ migrations
βΒ Β βΒ Β βββ 2024_06_10_154108_create_admins_table.php
βΒ Β βΒ Β βββ 2024_06_10_154108_create_users_table.php
βΒ Β βΒ Β βββ 2024_12_23_135704_create_password_reset_tokens_table.php
βΒ Β βΒ Β βββ 2025_01_29_171753_create_email_verifications_table.php
βΒ Β βββ seeders
βΒ Β βββ DatabaseSeeder.php
βββ src
βββ Admin.php
βββ AdminId.php
βββ AllAdmins.php
βββ AllAdminsUsingEloquent.php
βββ AllEmailVerifications.php
βββ AllEmailVerificationsUsingEloquent.php
βββ AllUsers.php
βββ AllUsersUsingEloquent.php
βββ ChangePasswordHandler.php
βββ CouldNotChangePassword.php
βββ CouldNotFindEmailVerification.php
βββ CouldNotFindUser.php
βββ CouldNotRegisterAdmin.php
βββ CouldNotRegisterUser.php
βββ CouldNotSendEmailVerificationNotification.php
βββ CouldNotStartEmailVerification.php
βββ CouldNotVerifyEmail.php
βββ Email.php
βββ EmailVerification.php
βββ ExpireEmailVerificationHandler.php
βββ FirstName.php
βββ FixedTokenGenerator.php
βββ GetUserUsingDatabase.php
βββ LastName.php
βββ Password.php
βββ RandomizedTokenGenerator.php
βββ RecordsEvents.php
βββ RegisterAdminHandler.php
βββ RegisterUserHandler.php
βββ ResetPasswordHandler.php
βββ ResetPasswordNotification.php
βββ Role.php
βββ SendEmailVerificationNotificationHandler.php
βββ StartEmailVerificationHandler.php
βββ Token.php
βββ TokenGenerator.php
βββ User.php
βββ UserId.php
βββ VerifyEmailHandler.php
βββ VerifyEmailNotification.php
Just by skimming it, you probably guessed what domain this belongs to: Identity and Access Management (IAM). Thatβs exactly why I chose it: itβs familiar, intuitive, and hard to misinterpret. Youβll spot concepts like email verification, admin registration, and password resets without breaking a sweat. But itβs also a great example of how things can get blurry, fast. EmailVerification.php
is obvious. RegisterAdminHandler.php
? Also clear. But AllUsers.php
? Hmm. That one gives us pause.
Wouldnβt it be nicer to find and understand things faster?
Grouping by type
Letβs rearrange the structure using the time-honored convention: group by class type.
.
βββ Concern
βββ Enum
βββ Exception
βββ Handler
βββ Interface
βββ Model
βββ Notification
βββ Repository
βββ Service
βββ ValueObject
Feels clean, right? It reduces noise. If you know you're looking for a model, you go to Model
. A repository? Head to Repository
.
.
βββ Model
βΒ Β βββ Admin.php
βΒ Β βββ EmailVerification.php
βΒ Β βββ User.php
βββ Repository
βΒ Β βββ AllAdminsUsingEloquent.php
βΒ Β βββ AllEmailVerificationsUsingEloquent.php
βΒ Β βββ AllUsersUsingEloquent.php
Great! Now I know that AllAdminsUsingEloquent
is a repository. Useful.
But...
Thatβs where the benefitβin my honest opinionβends.
Grouping by type helps only in the most trivial of cases: βI need to find a model.β But beyond that, this structure tells us nothing about what the code is actually trying to achieve. You lose context. Everything is organized from the perspective of the programming language β not the domain. Itβs as if we sorted all the tools in a garage by color instead of function (!).
And when non-devs get involved, it becomes even less helpful. When a project manager says,
Hey Muhammed, users are getting invalid tokens during email verification β can you look into it?
...your nicely color-coded, type-based folders donβt offer any shortcuts. Youβre left hunting for relevance.
So, I'd like to propose a second way of doing things.
Grouping by context / process
Alright, letβs cut to the chase. Here's what a context-driven structure might look like (Iβll skip the internal files for now):
.
βββ Admin
βΒ Β βββ Registration
βββ User
βββ EmailVerification
βΒ Β βββ Tokens
βββ PasswordChange
βββ PasswordReset
βββ RBAC
βββ Registration
Now this is speaking my language.
Just by glancing at it, I can already tell a lot more about the system than I could with the "by type" structure. I can clearly see that IAM is broken down into two major actors: User
and Admin
. I can also see that Admins only have a Registration
process, while Users have several β five, to be exact.
Letβs go back to what our project manager said:
Hey Muhammed, users are getting invalid tokens during email verification β can you look into it?
Seems vague, right? But now, I can almost project that sentence directly onto our folder structure. Let's start from the root.
Step 1: βusersβ
.
βββ Admin
βββ User
Cool, letβs expand User
.
.
βββ User
βββ EmailVerification
βββ PasswordChange
βββ PasswordReset
βββ RBAC
βββ Registration
Step 2: βtokens during email verificationβ
Alright, I donβt immediately see a folder related to tokens, but EmailVerification
jumps right out at me. Letβs go one level deeper.
βββ EmailVerification
βΒ Β βββ AllEmailVerifications.php
βΒ Β βββ AllEmailVerificationsUsingEloquent.php
βΒ Β βββ CouldNotFindEmailVerification.php
βΒ Β βββ CouldNotSendEmailVerificationNotification.php
βΒ Β βββ CouldNotStartEmailVerification.php
βΒ Β βββ CouldNotVerifyEmail.php
βΒ Β βββ EmailVerification.php
βΒ Β βββ ExpireEmailVerificationHandler.php
βΒ Β βββ SendEmailVerificationNotificationHandler.php
βΒ Β βββ StartEmailVerificationHandler.php
βΒ Β βββ Tokens
βΒ Β βββ VerifyEmailHandler.php
βΒ Β βββ VerifyEmailNotification.php
βββ PasswordChange
βββ PasswordReset
βββ RBAC
βββ Registration
Whoa. Okay. Looks like this process is pretty involved. But there's no need to panic: Weβve got one more keyword in that sentence: "tokens".
Letβs open up the Tokens
directory.
βββ Tokens
βΒ Β βββ FixedTokenGenerator.php
βΒ Β βββ RandomizedTokenGenerator.php
βΒ Β βββ Token.php
βΒ Β βββ TokenGenerator.php
Boom. Weβre in.
This structure just guided me, step-by-step, straight to the area of the code where I need to look just by tracing the PMβs sentence. No guesswork. No grep. No Cmd+Shift+F. Just context.
And the best part? Because this boundary is well-bounded, I know the token generation logic lives right here and not in some random shared Service
or Shared
directory. I don't need to look elsewhere and now can fully focus on solving the problem at hand.
Thatβs the payoff of context-first design.
Here's the full picture:
.
βββ Admin
βΒ Β βββ Admin.php
βΒ Β βββ AdminId.php
βΒ Β βββ AllAdmins.php
βΒ Β βββ AllAdminsUsingEloquent.php
βΒ Β βββ Registration
βΒ Β βββ CouldNotRegisterAdmin.php
βΒ Β βββ RegisterAdminHandler.php
βββ Contract
βΒ Β βββ Command
βΒ Β βΒ Β βββ ChangePassword.php
βΒ Β βΒ Β βββ ExpireEmailVerification.php
βΒ Β βΒ Β βββ RegisterAdmin.php
βΒ Β βΒ Β βββ RegisterUser.php
βΒ Β βΒ Β βββ ResetPassword.php
βΒ Β βΒ Β βββ SendEmailVerificationNotification.php
βΒ Β βΒ Β βββ StartEmailVerification.php
βΒ Β βΒ Β βββ VerifyEmail.php
βΒ Β βββ Event
βΒ Β βΒ Β βββ EmailVerified.php
βΒ Β βββ IdentityAccessManagementException.php
βΒ Β βββ Query
βΒ Β βΒ Β βββ GetUser.php
βΒ Β βΒ Β βββ User.php
βΒ Β βββ ServiceProvider.php
βββ Email.php
βββ FirstName.php
βββ LastName.php
βββ Password.php
βββ RecordsEvents.php
βββ User
βββ AllUsers.php
βββ AllUsersUsingEloquent.php
βββ CouldNotFindUser.php
βββ EmailVerification
βΒ Β βββ AllEmailVerifications.php
βΒ Β βββ AllEmailVerificationsUsingEloquent.php
βΒ Β βββ CouldNotFindEmailVerification.php
βΒ Β βββ CouldNotSendEmailVerificationNotification.php
βΒ Β βββ CouldNotStartEmailVerification.php
βΒ Β βββ CouldNotVerifyEmail.php
βΒ Β βββ EmailVerification.php
βΒ Β βββ ExpireEmailVerificationHandler.php
βΒ Β βββ SendEmailVerificationNotificationHandler.php
βΒ Β βββ StartEmailVerificationHandler.php
βΒ Β βββ Tokens
βΒ Β βΒ Β βββ FixedTokenGenerator.php
βΒ Β βΒ Β βββ RandomizedTokenGenerator.php
βΒ Β βΒ Β βββ Token.php
βΒ Β βΒ Β βββ TokenGenerator.php
βΒ Β βββ VerifyEmailHandler.php
βΒ Β βββ VerifyEmailNotification.php
βββ GetUserUsingDatabase.php
βββ PasswordChange
βΒ Β βββ ChangePasswordHandler.php
βΒ Β βββ CouldNotChangePassword.php
βββ PasswordReset
βΒ Β βββ ResetPasswordHandler.php
βΒ Β βββ ResetPasswordNotification.php
βββ RBAC
βΒ Β βββ Role.php
βββ Registration
βΒ Β βββ CouldNotRegisterUser.php
βΒ Β βββ RegisterUserHandler.php
βββ User.php
βββ UserId.php
You might have noticed a Contract
directory. This is not a synonym for the type Interface
. It represents the public contracts of the given module / boundary. If there are any JavaScript developers reading this blog post, this is more or less the same as module.exports
in a index.ts
barrel file. For the "design patterns" connoisseurs, I can compare this to the Facade
pattern (true facades, not the one Laravel borrows).
As an outsider, you are only allowed to depend on things inside this directory. The rest is considered internal.
Bonus example: Deep Linking
Note: Deep Linking refers to the ability to use standard web links to launch mobile applications. If the corresponding app is installed on the device, the link opens the app. Otherwise, it falls back to opening the equivalent page in the browser.
The following file structure belongs to a module called Deep Linking:
.
βββ config
βΒ Β βββ linking.php
βββ src
βββ Android
βΒ Β βββ AssetLinksDotJsonController.php
βΒ Β βββ DigitalAssetLinks
βΒ Β βΒ Β βββ CertificateFingerprint.php
βΒ Β βΒ Β βββ PackageName.php
βΒ Β βΒ Β βββ StatementList.php
βΒ Β βββ RouteRegistrar.php
βββ Contract
βΒ Β βββ RouteRegistrar.php
βΒ Β βββ ServiceProvider.php
βββ iOS
βββ AppleAppSiteAssociationController.php
βββ RouteRegistrar.php
βββ UniversalLinks
βββ ApplicationId.php
βββ Association.php
As you can see, two broad subdomains are immediately apparent: Android and iOS. Each has its own internal structure, which reflects the vendor-specific implementations of deep linking. While Android uses Digital Asset Links, Apple employs Universal Links. Both aim to solve the same problem, so they rightfully live within the same bounded context: Deep Linking.
In this particular scenario, itβs hard to imagine how a βgroup by typeβ structure could offer any meaningful clarity or navigational advantage. Organizing by process and platform makes the domain model speak for itself.
Wondering what those RouteRegistrar
files are? They implement a missing piece in Laravelβs core routing API, namely the concept of a modular RouteRegistrar
. You can read more about that here.
Which one is better?
In a vacuum, the answer is neither. What makes a structure βgoodβ or βbadβ is entirely contextual β shaped by your team's goals, scale, and the kind of work you do. That said, at the beginning of this post, we established a few expectations we want from a file structure: clarity, navigability (screaming architecture), alignment with business concerns (boundaries), and support for long-term maintainability (ease of change).
With those in mind, you can probably draw your own conclusions.
Side-by-side
Feature / Concern | Grouping by Type | Grouping by Context / Process |
---|---|---|
Discoverability by file type | β Easy to find all files of a certain type | β οΈ Types are scattered within contexts |
Change impact by technical concern | β Great for sweeping changes across similar classes (e.g. repos) | β οΈ Requires touching multiple contexts |
High-level view of architecture | β οΈ Obscures domain behavior, emphasizes tech stack | β Immediately shows domain structure and bounded contexts |
Mapping from business language | β οΈ Poor β requires mental translation from business terms | β Strong β directly mirrors stakeholder language |
Onboarding friendliness | β οΈ Needs orientation; not obvious how domain flows | β Easier β structure matches real-world features/processes |
Contextual isolation | β οΈ Types are loosely scoped across the app | β Code for a process lives together |
Scaling with team size | β οΈ More coordination required across shared types | β Teams can independently own and evolve contexts |
Navigation during feature work / debugging | β οΈ Requires jumping between directories | β Most relevant files are co-located |
Tactical refactoring (by type) | β Simple β update one kind of class system-wide | β οΈ More fragmented β refactoring one type may span contexts |
Promotes domain understanding | β οΈ Weak β domain knowledge is buried under technical layers | β Strong β structure reflects the business domain |
TL;DR:
-
Type-based grouping is great for tech-focused tasks, consistent naming, and large sweeping changes.
-
Context/process-based grouping shines for domain clarity, team ownership, debugging, and mapping business problems directly to code.
Again, I'm not saying that one is better than the other. Judge for yourself and be as productive as possible within your own constraints!
Join the discussion on X (formerly Twitter)! I'd love to know what you thought about this blog post.
Thanks for reading!