May 2nd, 2025 Design

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!