- Knowledge
- technology
- OOP
- Tips
- Programming
- Tips
- Tutorial
- SEO
- Ranking
- Knowledge
- Special Day
- Seo
- Bug
- Data science
- Seo
- artificial intelligence
- Machine Learning
- Robotics
- happyNewYear2021
- newYearEve
- 2021
- Automation
- Smart Home
- Career
- Best Practices
- Git
- Logging
- Web Fundamentals
- DNS
- HTTPS
- Performance
- AI Tools
- ChatGPT
- Claude
- Gemini
- Laravel
- Eloquent
- MySQL
- HTTPS
- TLS
- Web Security
- Certificates
- Developer Life
- Debugging
- Docker
- DevOps
- Transactions
- Queues
- LLMs
- AI
- AI Coding
- Developer Tools
- React Native
- Expo
- Kate PMS
- Mobile Apps
- Laravel
- Authentication
- Sanctum
- Cookies
- API Design
- Payments
- Idempotency
- DeepSeek
- Open Source AI
- LLMs
- AI News
- Git
- Version Control
- AI Coding
- Prompting
- PHP
- Checklist
- MCP
- AI Agents
- OpenAI
- Architecture
- Microservices
- Modular Monolith
- Estimation
- Developer Life
- Project Planning
- Humour
- OAuth
- OpenID Connect
- Authentication
- Embeddings
- Vector Search
- RAG
- pgvector
- OpenAI
- GPT-4.1
- Codex CLI
- Events
- Testing
- Clean Code
- Maintainability
- Code Review
- Webhooks
- API
- Security
- Claude Code
- Workflow
- AI
- LLM
- Prompt Injection
- Mobile
- React
- Networking
- TCP
- UDP
- HTTP/3
- CLAUDE.md
- AWS
- Cloud Security
- Backups
- PHPUnit
- Software Engineering
- Leadership
- Communication
- RAG
- Embeddings
- AI Engineering
- IT Infrastructure
- Networking
- Access Control
- CI/CD
- GitHub Actions
- Gemini CLI
- Claude Code
- JavaScript
- Async/Await
- Node.js
- Promises
- Security
- Cryptography
- Passwords
- MySQL
- Database
- Vibe Coding
- Software Quality
- DNS
- Code Reading
- Onboarding
- Productivity
- Background Jobs
- Developer Humour
- Estimates
- Dev Life
- JWT
- o3-mini
- DeepSeek R1
- Rate Limiting
- Kate PMS
- E-Signing
- Audit Trail
- REST
- GraphQL
- API Design
- Laravel 12
- Upgrade Guide
- Open Source
- Self-Hosting
- Task Scheduling
- Cron
- Secrets
- CORS
- PHP
- PHP-FPM
- OPcache
- GitHub Copilot
- Software Architecture
- Engineering
- TypeScript
- JavaScript
- Type Safety
- AI Security
- React Native
- Product Design
- AI Agents
- Kiro
- Queues
- Redis
- RabbitMQ
- AWS SQS
- Nginx
- Apache
- GPT-5
- gpt-oss
- Clean Code
- Architecture
- Naming
- Documentation
- Career
- ADR
- Teamwork
- Supply Chain
- Kate HRM
- HR Software
- Permissions
- System Design
- Pagination
- SSH
- Linux
- Big O
- Databases
- Laravel Boost
- MCP
- Developer Skills
- Validation
- Databases
- Indexes
- Code Quality
- Deployment
- Developer Humour
- Feature Flags
- Code Review
- Pull Requests
- Docker
- Cursor
- Authorization
- RBAC
- Gemini
- Long Context
- PHP 8.4
- Caching
- Dependency Injection
- Web Performance
- Browser
- CSS
- Database
- Migrations
- ChatGPT
- AI for Developers
- Monitoring
- On-Call
- REST
- Backend
- SQL
- NoSQL
- Database Design
- Coding Agents
- Claude 4
- API Resources
- REST API
- Load Balancing
- Scaling
- AWS
- AI Tools
- Claude
- Sora 2
- CTE
- 2FA
- TOTP
- Programming Languages
- Prompts
- Developer Workflow
- API Gateway
- APIs
- Passport
- API Auth
- Learning
- Burnout
- Developer Growth
- Web Development
- SEO
- Kate Mall
- ChatGPT Atlas
- Agent Skills
- Middleware
- Laravel 12
- Collections
- Context Window
- Monitoring
- Commit Messages
- Self Review
- Growth
- Regex
- Programming Basics
- Text Processing
- Database Design
- Normalization
- Linux
- Server Security
- Linux Foundation
- Open Standards
- Legacy Code
- Documentation
- AI Workflow
- File Uploads
- Test Data
- Hashing
- Performance
- Caching
- Enums
- Scope Creep
- Estimation
- Codex
- Gemini CLI
- Timezones
- Carbon
- Bugs
- PHP 8.5
- Gemini 3
- GPT-5.1
- Data Integrity
- Event Loop
- Async
- Opus 4.5
- AI Models
- React
- Forms
- Frontend
- Backups
- AI Images
- DALL-E
- Midjourney
- Race Conditions
- Concurrency
- Legacy Code
- Refactoring
- Senior Engineer
- Scope
- LLM
- CDN
- Web
- Sub-Agents
- Soft Deletes
- Audit Log
- Concurrency
- AI Learning
- NestJS
- AI Evals
- Policies
- SPF DKIM DMARC
- Unicode
- UTF-8
- Knowledge Graph
- Value Objects
- Technical Debt
- Feature Flags
- Laravel Pennant
- Deployment
- Copilot
- Composer
- Dependencies
- Artisan
- Automation
- AWS S3
- Object Storage
- Cloud
- Small Language Models
- Ollama
- Production
- Sessions
- HTTP
- Mentoring
- SQL
- Virtual Machines
- Web Development
- HTTP/2
- QUIC
- Web Performance
- AI Integration
- LLM API
- SOLID
- OOP
- Hosting
- Serverless
- Merge Conflicts
- Temperature
- AI Development
- Reverse Proxy
- Nginx
- Infrastructure
- Verification
- Passkeys
- WebAuthn
- Teams
- Communication
- Stakeholders
- Monorepo
- CI/CD
- Versioning
- JSON Schema
- Livewire
- Inertia
- Meetings
- Distributed Systems
- Privacy
- Full-Stack
- T-Shaped Skills
- Money
- Notifications
- Web Security
- HTTP Headers
- CSP
- Function Calling
- Load Testing
- k6
- Data Extraction
- Debugging
- WebSockets
- SSE
- Real-Time
- Laravel Reverb
- Infrastructure as Code
- Terraform
- Side Projects
- Laravel Pint
- OpenAPI
- Swagger
- UX
- Multimodal
- Jest
- Pair Programming
- APIs
- Rate Limiting
- Resilience
- Dev Humour
- Design Tokens
- JWT
- API Keys
- Sessions
- PHPStan
- Rector
- Incidents
- Reporting
- Dashboards
- Zero Trust
- IAM
- Search
- Laravel Scout
- Junior Developers
- Mentoring
- Images
- WebP
- AVIF
- Bug Reports
- Let's Encrypt
- Design Docs
- Software Design
- Observers
- Replication
- Accountability
- Data Structures
- Reliability
- LLM Memory
- Error Handling
- Payments
- Payment Gateway
- Webhooks
- PCI DSS
- Observability
- OpenTelemetry
- Personal Brand
- Writing
- Conventions
- Dates
- Scheduling
- Disaster Recovery
- Compression
- Brotli
- Deadlines
- Developer Habits
- State Machines
- Tech Roles
- UUID
- ULID
- Horizon
- Planning
- Engineering Culture
- Ownership
- Soft Skills
- Socialite
- Cost Control
- Collations
- Unicode
- Octane
- PostgreSQL
What I Learned Building Role-Based Access for 50+ Staff Users
About Post
On a whiteboard, role-based access looks simple. Admins can do everything, managers can approve things, everyone else can view things. Draw three boxes, add a role column, done by lunch.
Then real people start using the system. Someone in finance needs to see contracts but not edit them. A leasing officer needs to edit contracts, but only before they're signed. A new team appears after a reorganisation, with a mix of permissions that matches none of your three boxes.
Kate PMS, the property management system I lead the engineering on at Kate Group, is used by 50+ staff across very different jobs: leasing, finance, maintenance, management. The same backend serves the web portal and the staff app, and the data includes contracts, payments and tenant details. Getting access right there taught me more than any tutorial did. Here are the lessons I'd hand to anyone building it, in the order I wish I'd learned them.
Lesson 1: check permissions, never roles
The fastest way to paint yourself into a corner is code like this, scattered across controllers:
if ($user->role === 'manager' || $user->role === 'admin') {
// approve the contract
}
It works until the business says "team leads should approve too, but only renewals". Now you're hunting for every role === in the codebase.
The fix is a layer of indirection: code checks permissions; roles are just named bundles of permissions. The code asks "can this user approve contracts?", and whether that's true is data, not code. When the organisation changes, you edit a role, not a dozen files. Whether you build this yourself or use a package like spatie/laravel-permission, the principle is the same.
Lesson 2: policies answer "this record", scopes answer "which records"
Laravel policies are the natural home for authorization logic, one class per model:
class ContractPolicy
{
public function update(User $user, Contract $contract): bool
{
return $user->hasPermission('contracts.edit')
&& ! $contract->isSigned();
}
public function approve(User $user, Contract $contract): bool
{
return $user->hasPermission('contracts.approve');
}
}
And in the controller, one line: Gate::authorize('update', $contract);. (A simplified sketch: hasPermission() stands in for whatever your permission lookup is.)
But here's the gap people miss: a policy answers a yes/no question about one record. It does nothing for a list. You can protect the "view contract" page perfectly and still leak everything through the index endpoint, the search box, the export button or a dashboard count.
So every "which records can this user see?" question gets a query scope, used everywhere a list is built:
public function scopeVisibleTo(Builder $query, User $user): Builder
{
return $user->hasPermission('contracts.view_all')
? $query
: $query->where('assigned_to', $user->id);
}
// Contract::visibleTo($request->user())->paginate();
Single record: policy. Lists, searches, exports, counts: scope. You need both.
Lesson 3: start from zero, not from "everything"
When someone joins and nobody's sure what they need, it's very tempting to give them a broad role "for now". "For now" has a way of becoming permanent.
Least privilege means a new account starts with the minimum for its job, and more is granted when there's a real need. It feels slower at first. It's dramatically easier than trying to take access away later, when someone has come to rely on it.
The same thinking applies in code: deny by default. If a policy method doesn't exist or a permission isn't granted, the answer is no. Be very careful with shortcuts like Gate::before() returning true for admins: convenient, but it skips every rule, including ones like "nobody edits a signed contract" that should apply to admins too.
Lesson 4: the UI is not the security boundary
Hiding the "Approve" button for users who can't approve is good UX. It is not security. The API endpoint behind that button must check the permission itself, because anyone can call an API directly, and an older version of a mobile app may still show a button you've since removed.
The rule I follow: every endpoint authorizes on the server, and the UI only mirrors that decision. A nice side effect: the frontend can ask the API what the current user may do (a list of abilities in the user payload), so the web portal and the staff app hide the same buttons for the same reasons.
The two-question test for any new feature: who can do this to one record (policy), and which records can they see in the first place (scope)? If you can't answer both, the feature isn't ready.
Lesson 5: test permissions as a matrix
Permission bugs are silent. Nothing crashes when the wrong person can see a payment. You only find out when someone mentions it, which is the worst way to find out.
So permissions deserve their own tests, written as a table of role, action and expected result:
#[DataProvider('approvalMatrix')]
public function test_contract_approval_permissions(string $role, int $status): void
{
$user = User::factory()->withRole($role)->create();
$contract = Contract::factory()->pending()->create();
$this->actingAs($user)
->postJson("/api/contracts/{$contract->id}/approve")
->assertStatus($status);
}
public static function approvalMatrix(): array
{
return [
'approver can approve' => ['approver', 200],
'editor cannot approve' => ['editor', 403],
'read-only cannot approve' => ['viewer', 403],
];
}
(Role names here are illustrative.) The negative cases matter more than the positive ones. "Approver can approve" gets tested by every user, every day. "Viewer cannot approve" only gets tested by your test suite. Do the same for list endpoints: assert that a restricted user's index response doesn't contain a record they shouldn't see.
Lesson 6: audit who did what, and who changed who can do what
For anything that matters (contract changes, approvals, payment adjustments), record who did it, when, and what changed. When a question comes up months later, "the system did it" is not an answer anyone accepts.
And don't forget the meta-level: changes to permissions themselves should be audited too. If someone was granted access to finance data, you want to know who granted it and when. That's often the first question in any review.
Lesson 7: name permissions in business language
Permissions will be managed by people who don't read code. contracts.approve is clear. can_do_action_7, or a permission named after a controller method, is not. Group them by area and write them so a manager can look at a role and understand exactly what it allows.
Clear names also make reviews easier. A pull request that adds payments.refund to a role is obviously worth a second look.
The short version
- Code checks permissions. Roles are just bundles of them.
- Policies for one record, scopes for every list, search and export.
- Start with the minimum, deny by default, be wary of admin shortcuts.
- Authorize every endpoint on the server. The UI only mirrors it.
- Test the "cannot" cases as carefully as the "can" cases.
- Audit actions, and audit permission changes.
None of this is glamorous. Nobody demos a permission matrix. But when 50+ people with different jobs share one system, it's the part that decides whether they can trust it.
What's the trickiest permission rule you've had to model? I'd love to hear the ones that didn't fit neatly into roles.

Be first to comment it...