
Over the past few weeks, I’ve been shaping the core structure behind Simple GRC — specifically how all the governance, risk, and compliance content connects together.
I wanted something clean, logical, and scalable. GRC data tends to get messy fast — one framework references another, controls overlap, requirements duplicate, and every “simple mapping” turns into a web of dependencies.
To handle that without losing my mind, I built a model centered around Libraries, Objects, and Requirements.
It sounds simple (and that’s the point), but there’s quite a bit going on under the hood.
📚 Libraries — the Source Layer
The libraries table defines the highest level — the source of content.
| Column | Type | Notes |
|---|---|---|
id | bigint | Primary key |
name | string | e.g., “NIST 800-53”, “CIS Controls”, “Internal Policy Framework” |
slug | string | Used in URLs for cleaner routing |
description | text | Short description shown under the library name |
category | enum | Defines if it’s a Control, Risk, Policy, etc. |
is_active | boolean | Used for toggling availability in dropdowns |
Each library can have types — stored in a related table library_types, linked via library_id.
For example, the “Controls” library might have Preventative, Detective, and Corrective as its types.
This allows one library to handle variations without creating multiple separate libraries.
🧩 Objects — the Content Layer
Objects are the individual building blocks that live inside libraries.
| Column | Type | Notes |
|---|---|---|
id | bigint | Primary key |
library_id | bigint | FK to libraries |
type_id | bigint | FK to library_types |
name | string | Object title |
description | text | Optional, shown under the title |
parent_id | bigint (nullable) | Allows hierarchical grouping |
order | int | Sort order |
instance_id | bigint | Multi-instance separation |
Objects belong to a library, and their type filters are context-aware — meaning when you switch tabs on the UI, only the types relevant to that library show up.
Page Logic:
Each Library page uses a Livewire component (LibraryObjects) that handles:
- loading all objects under the selected library (with pagination)
- applying the selected
type_idfilter (if any) - showing a pill next to each object with the count of its total requirements
- lazy-loading counts via subqueries to avoid slow joins
When a tab is switched, the tab state persists in the URL (e.g. /libraries/objects?tab=controls&type=preventative), so if you navigate away and come back, you land exactly where you left off.
Each object card shows:
- title and description
- a small badge like
12 reqsif requirements exist - a dropdown to access deeper details or mappings
If no requirements exist, the badge simply doesn’t render — keeping the UI clean.
✅ Requirements — the Action Layer
Requirements are the actual actionable elements tied to objects.
| Column | Type | Notes |
|---|---|---|
id | bigint | Primary key |
object_id | bigint | FK to library_objects |
parent_id | bigint (nullable) | For nested or dependent requirements |
description | text | Core requirement content |
is_mandatory | boolean | Indicates if it’s a must-have |
weight | int | Relative importance or control weight |
instance_id | bigint | Keeps multi-instance separation |
The recursive relationship through parent_id allows the system to display a tree of requirements.
The total count of all descendant requirements for each object is pre-calculated using a recursive CTE or cached accessor.
That number is displayed as the pill indicator under each object, giving a quick overview of scope or implementation complexity.
⚙️ Livewire Logic and Filtering
Each tab and component relies on a lightweight Livewire setup:
- A root component handles library switching and type filters.
- Child components (
LibraryObjects,ObjectRequirements) manage data specific to the selection. - The selected tab and filter are passed through the query string to keep navigation smooth and back-button friendly.
- For large libraries, requirements counts are calculated asynchronously after the main list renders — this avoids slow initial loads.
Example:
public function getRequirementCountProperty()
{
return Requirement::where('object_id', $this->id)
->with('children')
->get()
->flatMap(fn($r) => $r->allDescendants())
->count();
}
🎨 UI Behavior
The interface is built in Tailwind + Livewire:
- Tabs across the top for libraries (sticky header)
- Filter dropdown for type selection (dynamic per library)
- Object cards in a grid layout with responsive wrapping
- Requirement counts as rounded badges
- Descriptions in small text under each object title
The layout is designed to adapt automatically — no fixed heights, full responsiveness, and clean transitions between libraries and filters.
🧠 Why This Structure Works
This model scales well because:
- Libraries define context.
- Objects define content units.
- Requirements define actionable obligations.
Each layer is tightly scoped yet loosely coupled — meaning you can expand horizontally (add new libraries and types) or vertically (add new depth to requirements) without breaking existing relationships.
It also supports custom fields, which will be introduced next. Each library and library+type combination will be able to define its own field set — for example, a “Preventative Control” could have a “Detection Method” field that doesn’t apply to other types.
🧭 What’s Next
The next iterations in Simple GRC will focus on:
- Implementing custom field definitions per library and type.
- Adding mapping relationships between objects (Control ↔ Risk ↔ Policy).
- Enabling requirement inheritance across linked objects.
- And eventually, building an intuitive Mappings UI where users can connect anything to anything — visually and interactively.
The goal has always been simplicity — a system where GRC data feels structured but not rigid, relational but still intuitive.
The Library → Object → Requirement model is now doing that — turning a messy web of frameworks, controls, and mandates into something clean, traceable, and actually usable.






Leave a Reply