Enterprise Project Management Platform
Full project lifecycle and time-tracking system with automated billing, third-party integrations, and strict financial controls for professional services
The Challenge
A professional services firm needed a system to manage the complete project lifecycle: from initial enquiry through quote approval, active work, invoicing, and final payment. The platform had to handle 100sprojects annually[src] with complex requirements around dual time-tracking systems, automated document generation, and integration with 3external systems[src].
The core challenge: prevent revenue leakage. Make sure no billable work occurs without proper commercial agreements. The solution needed to enforce business rules around credit control, payment terms, and project budgets at the system level, not through developer discipline.
Show the scale of the problemHide the scale of the problem
Managing hundreds of projects annually with varying payment structures creates serious operational complexity. Consider the combinations: 2 payment structures (deposit vs full payment) x 3 project states (enquiry, active, complete) x 2 time-tracking systems (attendance vs billable) x 3 integration points (issue tracking, chat, document signing).
Without systemic prevention, developers could potentially log billable hours against unapproved projects, unsigned quotes, or unpaid deposits. Each scenario represents unbillable work—revenue lost forever. The firm needed validation that made these scenarios physically impossible, not just procedurally discouraged.
Technical Approach
The system architecture follows Domain-Driven Design principles with 422service classes[src] orchestrating behaviour across 30+entity types[src]. Complex domain concepts like Duration, Total, and PaymentTerms are modelled as immutable value objects with encapsulated business logic.
The architecture uses 5distinct layers[src]: entities for domain objects, repositories for data access, services for orchestration, value objects for immutable concepts, and event listeners for decoupled state transitions.
Show what each layer doesHide what each layer does
Entities represent core business objects like Project, Invoice, and TaskLog. Built with Doctrine ORM, these aren't simple data containers—they contain rich domain behaviour like state transitions, validation rules, and business calculations.
Repositories handle all data access. Custom query methods encapsulate complex database logic: “find all projects with unpaid invoices” becomes a single method call rather than scattered SQL across services. This abstraction makes testing easier and keeps services focused on business logic.
Services orchestrate workflows that span multiple entities. The ClockingService, for example, coordinates between TaskLog entities, Project entities, validation checks, and event dispatching. Services contain no database logic—they delegate to repositories—and no business rules—those live in value objects and entities.
Value Objects are the secret weapon. Duration, Total, and PaymentOptionValue are immutable types that encapsulate complex calculations. Add two Totals together? The VAT recalculates automatically. Convert hours to days? The 6-hour developer day logic lives in Duration itself, not scattered across the codebase.
Show why Doctrine ORM instead of Eloquent?Hide why Doctrine ORM instead of Eloquent?
The team chose Doctrine ORM over Laravel's default Eloquent ORM for its superior support of embedded value objects and data mapper pattern. This enabled financial calculations and business rules to live directly within domain objects rather than being scattered across services.
Doctrine's embeddable pattern allowed complex types like Duration, Total, and PaymentOptionValue to be stored as multiple columns whilst presenting a single cohesive object to application code—impossible with Eloquent's active record approach.
The trade-off was steeper learning curve and more verbose configuration, but the payoff came immediately: financial calculations that would have required 50+ lines of scattered service code became single method calls on value objects. When a VAT rate changes, you update one class, not hunt through 20 services.
Revenue Protection Through Validation
The Problem: Developers could potentially log billable hours against projects that hadn't been properly approved, had exceeded budgets, or lacked signed agreements. Each scenario represents unbillable work—revenue lost forever.
The Solution: A layered validation system checks 7distinct conditions[src] before allowing time entry. The clocking service enforces business rules at the system level, making revenue leakage physically impossible rather than merely discouraged.
Show what makes this different from procedural checks?Hide what makes this different from procedural checks?
Traditional systems rely on developer discipline: “Remember to check if the quote is signed before starting work.” This breaks down under pressure. A client calls with an urgent issue. A developer starts work, intending to chase the paperwork later. By the time finance discovers the unsigned quote, you've already delivered billable work you can't invoice.
This system makes that scenario impossible. The time-logging form calls the clocking service, which checks seven conditions before accepting the entry. No signed quote? The form displays an error: “Quote must be signed before logging time.” The developer can't proceed. The client conversation shifts from “I'll start immediately” to “I need your signature on the quote first.”
The validation isn't a polite suggestion—it's a database-level constraint enforced by the service layer. You can't bypass it through the UI, you can't bypass it through the API, you can't bypass it through direct database access (foreign key constraints prevent orphaned records). The system architecture itself prevents revenue leakage.
The validation logic implements these revenue protection principles in practice through a multi-layered checking mechanism.
Understanding the validation flow is essential, but equally important is understanding how the system responds when validation fails.
Show what happens when validation fails?Hide what happens when validation fails?
Failed validation returns a ValidationCheck object with a specific error message. The UI displays this directly to the developer: “Deposit invoice not paid” or “Working agreement not signed.” Clear, actionable feedback that identifies the exact blocker.
Behind the scenes, these failures are logged for business intelligence. Finance can run reports: “Which projects have unsigned quotes blocking development?” Management gets visibility into process bottlenecks. A spike in “deposit not paid” failures might indicate cash flow issues requiring attention.
Financial Precision
The Problem: Standard floating-point arithmetic causes rounding errors in financial calculations. Add enough invoices together and you get discrepancies between the sum of line items and the total. Accountants notice. Clients complain. Regulatory compliance breaks.
The Solution: All monetary values are stored as minor units (pennies) using the Money PHP library. Arithmetic operations happen on integers—no rounding errors possible.
Show why integer pennies instead of decimal pounds?Hide why integer pennies instead of decimal pounds?
Consider a typical VAT calculation: £123.45 ex-VAT at 20%. The VAT should be £24.69, giving a total of £148.14. Simple maths.
With floating-point decimals: 123.45 * 1.20 = 148.14000000000001. Round it to 2 decimal places and you get £148.14 — correct by accident. But do a hundred of these calculations, sum them up, and the accumulated error grows. Eventually you're a penny off. Then five pence off. Your books don't balance.
With integer pennies: 12345 * 120 / 100 = 14814. Exact integer arithmetic. Sum a thousand invoices and the total is still exact. No accumulated error. No mysterious penny discrepancies at month-end. The system stores 12345 pennies, displays “£123.45”, and every calculation is precise.
This precision-focused approach is implemented throughout the financial layer using dedicated value objects that encapsulate integer-based arithmetic.
Show what about currency conversion and exchange rates?Hide what about currency conversion and exchange rates?
This system handles only GBP (British pounds), so currency conversion isn't a concern. But the pattern extends: store foreign currency amounts as minor units in their native currency, then convert at the API boundary when needed. Exchange rates as rational numbers (numerator/denominator pairs) maintain precision through complex multi-currency calculations.
The Money PHP library supports 100+ currencies and handles all the edge cases: currencies with 3 decimal places (Kuwaiti dinar), currencies with no decimal places (Japanese yen), currencies with unusual rounding rules. The abstraction scales far beyond the single-currency scenario in this case study.
Dual Time-Tracking Architecture
The Problem: The organisation needed to track both office attendance (for HR/payroll) and billable project time (for client invoicing). These are fundamentally different concepts with different business rules, but traditional systems force them into a single “time tracking” bucket.
The Solution: Two completely separate entity hierarchies were implemented: Register for office attendance and TaskLog for billable time. Different validation rules, different database schemas, different reporting logic—clean separation enables independent evolution.
Show what's fundamentally different about these concepts?Hide what's fundamentally different about these concepts?
Office attendance is continuous: you clock in at 09:00, work through the day, clock out at 17:30. The system records a single time block. HR cares about total hours present, break compliance, and overtime calculations. The question is: “Was this person in the office?”
Billable time is discrete: you spend 2.5 hours on Task A, then 1 hour on Task B, then 45 minutes on Task C. Three separate entries, each linked to a specific project and task. Finance cares about which client to invoice, which task consumed the time, and whether the work was development or management. The question is: “What did this person bill?”
An admin staff member clocks in daily but never logs project time—they're not billable. A remote consultant logs project time without clocking into an office—they work from home. A developer might have 8 hours of office attendance but only 6 hours of billable time—the difference is meetings, emails, and breaks. Two separate concerns, tracked separately.
Key Architectural Differences
- Registers: Single continuous time block, nullable end time for open sessions, belongs to User
- TaskLogs: Discrete duration entries, mandatory task linkage, belongs to Project
- Validation: Registers check nothing (always permissible); TaskLogs check 7 conditions
- Reporting: Attendance reports by user/location; billing reports by project/client
Show why separate systems instead of categories?Hide why separate systems instead of categories?
A single time-tracking system with different categories (a “type” column distinguishing attendance from billable time) would have forced shared validation rules and database constraints. The attendance check would run on every billable entry. The project linkage foreign key would need to be nullable to accommodate attendance records. Reporting queries would require constant filtering: “WHERE type = 'billable'”.
Worse, the systems evolve at different rates. HR requirements change independently from finance requirements. A new attendance policy (mandatory break tracking) shouldn't risk breaking billable time logging. A new billing feature (separate dev vs management rates) shouldn't touch attendance code.
Separating the systems enabled independent schemas, independent validation, and independent evolution. When requirements diverged—as they inevitably did—no refactoring was needed. The decision, made early, prevented significant technical debt later.
Flexible Payment Workflows
The Problem: Different clients require different payment structures, each with different project state transitions and due date calculations. A schema column per payment option doesn't scale—you'd need database migrations for every new payment term.
The Solution: A polymorphic strategy pattern for payment terms, stored as class name plus configuration value. New payment strategies can be added without database schema changes.
Show why polymorphic storage instead of enums?Hide why polymorphic storage instead of enums?
Traditional approaches use enums: payment_type: DEPOSIT_50 | FULL_PAYMENT. This breaks down when payment terms need parameters. “Net 30” and “Net 60” are the same pattern (due X days after invoice) but with different values. You end up with NET_30, NET_45, NET_60 enum values — not scalable.
Polymorphic storage solves this: store the strategy class name (NetXDays) plus the configuration value (30). The database schema never changes. Add “Net 90”? Just use the same NetXDays class with value 90. Add “10% discount if paid within 14 days”? Create a new EarlyPaymentDiscount strategy class. The payment terms column stays unchanged.
The system uses a whitelist to prevent arbitrary class instantiation (security protection), but within that whitelist, payment terms are infinitely extensible. Each strategy class encapsulates its own due date logic, deposit requirements, and state transition rules.
The polymorphic approach extends beyond theory into a concrete implementation that stores payment strategies as first-class domain objects.
Show how does this affect project state transitions?Hide how does this affect project state transitions?
Payment structure determines when projects become active. With full payment on completion, the project moves to “Unfinished” (active work permitted) immediately after quote approval. With 50% deposit required, the project stays in “Quote” state until the deposit invoice is paid—the seven-layer validation prevents billable work until funds are secured.
The state transition logic lives in event listeners. When an invoice is marked paid, the InvoicePaidListener checks the payment terms strategy: is this a deposit invoice? If yes, enable work. Is this the final invoice? If yes, mark project complete. The payment strategy informs the state machine, but doesn't control it directly—clean separation of concerns.
The Six-Hour Developer Day
One of the most interesting decisions in this system was defining a six-hour developer day instead of the standard eight-hour day. This isn't a limitation—it's a recognition of reality.
Developers spend time in meetings, responding to emails, reviewing code, context switching between tasks, and taking breaks. Billing for 8 hours when only 6 are genuinely productive creates pricing misalignment. Clients expect 8 hours of output, but receive 6 hours worth. The 6-hour day makes billing honest and sustainable.
Show the maths behind the 6-hour dayHide the maths behind the 6-hour day
Consider a typical developer's day. Arrive at 09:00, leave at 17:30. That's 8.5 hours present. But how much is genuinely productive?
- Stand-up meeting: 30 minutes (09:00-09:30)
- Email and Slack: 45 minutes distributed throughout day
- Code review: 30 minutes (reviewing colleagues' PRs)
- Lunch break: 60 minutes (unpaid, not billable)
- Context switching: 30 minutes (lost productivity between tasks)
Total non-productive time: 2.5 hours. Genuinely productive time: 6 hours. The system models this reality rather than pretending developers are coding machines for 8 hours straight.
This business logic manifests in the codebase through the Duration value object, which encapsulates the six-hour day definition at the domain level.
Beyond the technical implementation, this domain model choice has profound implications for how the business approaches pricing and client relationships.
Show why this matters for billing and estimationHide why this matters for billing and estimation
Traditional 8-hour billing creates systematic under-delivery. Client contracts quote “20 developer days” (160 hours). Finance divides by £80/hour to get £12,800 project cost. But the client expects 160 hours of productive output, whilst the firm can only deliver 120 productive hours (6 hours/day x 20 days). The project feels 25% under-resourced before it even starts.
The 6-hour model fixes this. Quote “20 developer days” (120 productive hours). Set rates at £107/hour to maintain £12,800 revenue. Client receives 120 hours of productive output — exactly what they paid for. No systematic under-delivery. No hidden buffer needed in estimates. Pricing is transparent and honest.
This model also prevents developer burnout. Nobody expects 8 hours of billable output daily. The system won't even let you log it—validation rejects excessive hours. The architecture itself enforces sustainable work practices. You can't accidentally create a death march by over-logging hours, because the system knows that's physically impossible.
Results and Business Value
The system delivers measurable value across 4dimensions[src]: code quality, revenue protection, audit compliance, and automation. Each dimension reflects deliberate architectural choices that turned business requirements into system constraints.
Codebase Metrics
- 30,475lines[src] of application code across 422service classes[src]
- 205test files[src] providing regression coverage
- 77controllers[src] managing HTTP request/response
- 35+migrations[src] tracking schema evolution
Show what do these numbers tell us?Hide what do these numbers tell us?
The 422 service classes represent business logic orchestration—each service coordinates multiple domain objects to accomplish a workflow. The 205 test files indicate serious investment in regression prevention, though coverage could be more comprehensive for edge cases in financial calculations.
The ratio matters: 30,475 lines of application code supported by test coverage suggests roughly 1 test file per 150 lines of application code. Not exhaustive, but substantial. The 77 controllers handling HTTP are thin layers delegating to services—proper separation of concerns. The 35+ migrations tell a story of iterative development and continuous schema refinement.
Business Outcomes
- Revenue Protection: Seven-layer validation makes unbillable work physically impossible
- Financial Accuracy: Integer-based money handling eliminates floating-point rounding errors
- Audit Compliance: Event-driven architecture provides complete state transition history
- Automation: Document generation pipeline eliminates manual quote/invoice creation
Show how event-driven architecture enables audit complianceHide how event-driven architecture enables audit compliance
Every state transition in the system triggers a domain event. Invoice paid? Event dispatched. Quote signed? Event dispatched. Project status changed? Event dispatched. Listeners handle these events: update derived state, send notifications, log audit entries. The audit trail is a side effect of normal operation, not a separate concern requiring careful coordination.
This architecture makes compliance almost effortless. Regulators need evidence of approval workflows? Query the event log for all QuoteSigned events. Finance needs to understand when projects became active? Search for ProjectStatusChanged events with newState=Unfinished. The system records everything automatically because events are the mechanism of state change, not an afterthought.
Lessons Learned
What Worked Well
Domain-Driven Design: The investment in rich domain modelling paid dividends in maintainability. Business rules live in value objects and entities, not scattered across services. When VAT rates changed, one class update propagated throughout the system.
Event-Driven Architecture: Decoupling state transitions from business logic enabled clean, testable code and straightforward audit logging. Adding new event listeners never requires touching existing state transition code.
Doctrine ORM: The choice of Doctrine over Eloquent proved valuable for complex value object mappings that would have been awkward with active record patterns. The steeper learning curve paid off immediately.
Show the real cost of these architectural decisionsHide the real cost of these architectural decisions
Domain-Driven Design isn't free. The upfront cost is substantial: hours spent modelling Duration, Total, and PaymentOptionValue as proper value objects rather than primitive strings and integers. Hours spent designing the entity graph, establishing aggregate boundaries, defining repository interfaces. Early in development, this feels like over-engineering.
The payoff comes later, when you need to add a new payment term or handle a VAT rate change. Without value objects, you'd grep the codebase for every place that calculates VAT, update each one carefully, hope you didn't miss any, and pray your tests catch errors. With value objects, you update Total::create() once, re-run tests, done. The upfront investment compounds over time.
The same applies to event-driven architecture. Writing event listeners and dispatchers feels heavy-handed when a simple function call would work. But six months later, when you need audit logging for compliance, the events are already there. When you need to integrate with external systems, the notification hooks already exist. Early architectural investment prevents entire classes of future work.
Key Takeaways
- Separate concerns early: The dual time-tracking decision, made early, prevented significant refactoring later
- Invest in value objects: Upfront cost of implementing proper value objects saves debugging time in financial calculations
- Events over direct calls: Event-driven transitions enable clean separation and are easier to test than procedural state changes
- Architecture prevents bugs: Integer money storage eliminates floating-point errors—entire class of bugs impossible
Show areas for future improvementHide areas for future improvement
The system is production-stable and delivers business value. Still, several areas could benefit from modernisation:
- Frontend Migration: Vue.js is approaching end-of-life; migration to Vue 3 would be prudent
- Test Coverage: 205 test files exist, but coverage could be stronger for edge cases in financial calculations
- Performance: Some collection classes are deprecated in favour of native SQL queries, indicating ongoing optimisation work
- API Layer: Current system is UI-first; a proper REST API would enable mobile apps and third-party integrations
These improvements would further enhance an already solid system.
Applicability
The architectural patterns and domain modelling techniques demonstrated in this case study are directly applicable to:
Verticals
- Professional Services: Consultancies, agencies, contractors requiring time-based billing
- Legal & Accounting: Firms needing precise time tracking with audit requirements
- Software Development: Development shops managing multiple client projects
- Engineering Services: Technical consultancies with complex project lifecycles
Transferable Patterns
- Dual Tracking Systems: Any scenario requiring separate but related tracking (e.g., inventory vs sales)
- Event-Driven State Machines: Complex workflows with validation requirements
- Embedded Value Objects: Financial systems requiring precision and business logic encapsulation
- Strategy Pattern for Configuration: Flexible business rules without schema migrations
Need a similar system for your business?
We specialise in building enterprise-grade project management platforms with Laravel, DDD patterns, and strict financial controls.
Ready to eliminate your technical debt?
Transform unmaintainable legacy code into a clean, modern codebase that your team can confidently build upon.