What is Baleada Edge?
Updated on April 5, 2025Source code
Baleada Edge makes it easy to model relational data as an edge list where nodes are data objects and edges are the relationships between them.
It's inspired by DynamoDB, MongoDB, and single-table NoSQL design patterns more generally, but it works with more traditional database tech like SQLite, MySQL, Postgres, etc.
Here's a example of a Baleada Edge list that models polymorphic many-to-many relationships between users, projects, and tasks:
Upsides and downsides
The upside of this modeling strategy: Instead of writing glue code (e.g. helper tables, Laravel Eloquent model methods, and bespoke abstractions) to model complex relationships, you just use Baleada Edge to insert a new from
/to
pair into your edge list. Complex relationships are cheap to create and easy to delete as your app grows and changes.
// In a Laravel app, relate a user to a project:
use Baleada\Edge\Model;
class Edge extends Model {}
Edge::create([
'from_kind' => 'user',
'from' => 1,
'kind' => 'leads',
'to_kind' => 'project',
'to' => 1,
'profile' => [
'user' => [...],
'project' => [...],
],
]);
When you retrieve data, you can run a single, relatively simple query to get pre-joined results for any access pattern.
// Get all projects led by a specific user:
Edge::from('user', 1)->kind('leads')->to('project')->get();
The downside of this data modeling strategy: profile data for each object is denormalized and duplicated in the profile
column across many rows. When you write to the database, you have to update multiple rows to keep the data consistent.
// Update a user's email address
$userProfile = Edge::from('user', 1)
->kind('is')
->to('profile')
->first()
->profile->user;
$newUserProfile = [
...$userProfile,
'email' => $newEmail,
];
// Write new data to every edge that references the user's profile
Edge::from('user', 1)->toIn(['profile', 'project', 'task'])
->update(['profile->user' => $newUserProfile]);
Also, as relationships get more complex, the queries to retrieve data tend to search nested JSON values, which is less performant than searching traditional columns:
// Store a message sent from one user to another in a chat session
Edge::create([
'from_kind' => 'user',
'from' => 1,
'kind' => 'messaged',
'to_kind' => 'user',
'to' => 2,
'profile' => [
'userFrom' => [...],
'userTo' => [...],
'message' => [...],
'session' => [...],
],
]);
// Get all messages sent to a user during a specific chat session
Edge::to('user', 2)
->kind('messaged')
->profile('session->id', $sessionId)
->get();
Installation
Right now, Baleada Edge supports Laravel, and can be installed with Composer:
composer require baleada/laravel-edge
Usage
Baleada Edge provides two PHP classes: Migration
and Model
.
Baleada\Edge\Migration
extends Laravel's own Migration
class. It will create a table with the necessary columns for your edge list.
Baleada\Edge\Model
extends Laravel's Eloquent\Model
class. It pre-configures $fillable
fields and $casts
for your edge list, and also comes with a ton of built-in query methods, tailored to the columns created by the Migration
class.
With these two tools, you can query complex
Customizing Migration
You can customize the Baleada Edge migration by extending the Migration
class.
By default, Migration
will create a table named edges
. To change this, set the $table
property on your class:
use Baleada\Edge\Migration;
return new class extends Migration
{
protected $table = 'custom_edges';
}
Migration
also adds the required columns for your edge list, with some default types. Here's a breakdown of the columns, their purposes, and their default types:
id
id
kind
from
and to
nodes.string
from_kind
from
nodestring
from
from
nodeinteger
to_kind
to
nodestring
to
to
nodeinteger
profile
json
created_at
timestamp
updated_at
timestamp
The types of all columns except profile
, created_at
, and updated_at
can be customized by overriding the columns
method in your migration.
columns
should return an associative array, where each key is a column name, and each value can be one of two things:
- The string name of a Laravel
$table
method (e.g.string
,integer
,json
, etc.), - OR a callback, whose first parameter is the name of the column it's setting up, and whose second parameter is the table (
Illuminate\Database\Schema\Blueprint
). (This is considered more dangerous, because you should only use your callback to modify the intended column, but it technically has the power to modify the table in any way.)
Here's an example of a migration that uses string
for the from
and to
columns, and configures from_kind
and to_kind
to be an enum
of user
and post
:
use Baleada\Edge\Migration;
use Illuminate\Database\Schema\Blueprint;
return new class extends Migration
{
private $nodeKinds = ['user', 'post'];
protected function columns(): array
{
return [
// For simple type customizations, just provide
// the type name:
'from' => 'string',
'to' => 'string',
// For more complex type customizations, provide
// a callback that receives the column name and
// the table:
'from_kind' => fn (
string $name,
Blueprint $table
) => $table->enum($name, $this->nodeKinds),
'to_kind' => fn (
string $name,
Blueprint $table
) => $table->enum($name, $this->nodeKinds),
];
}
}
If you want to add additional columns to your edges table, you can include more keys and values in the associative array returned by columns
.
Customizing Model
Just like with Migration
, you can customize the Baleada Edge model by extending the Model
class.
For example, if you want your edges to use ULIDs instead of the default auto-incrementing integers, you can extend Model
and use Laravel's HasUlids
trait:
use Baleada\Edge\Model as Model;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
class Edge extends Model
{
use HasUlids;
}
Querying with Model
Model
comes with a ton of built-in query methods, tailored to the columns created by the Migration
class.
Here's a breakdown of the most commonly used methods:
from($fromKind[, $fromId])
from
nodeto($toKind[, $toId])
to
nodekind($kind)
profile($key, $value)
profile
columnUnder the hood, from
, to
, and kind
are thin wrappers around Laravel Eloquent's where
method, and profile
is a thin wrapper around the whereJsonContains
method.
Baleada\Edge\Model
also adds thin wrappers around all other Eloquent query methods, like orWhere
, whereIn
, whereJsonNotContains
, etc.:
// Get all edges where the `from` node is a user with ID 1
Edge::from('user', 1)->get();
// Get all edges from users or projects
Edge::from('user')->orFrom('project')->get();
// Get all edges where the `to` node is a post or comment
Edge::toIn(['post', 'comment'])->get();
// Get all edges from users to the tasks they _don't_ own
Edge::from('user')->kindNot('owns')->to('task')->get();
// Get all edges for tasks that are not marked "to-do"
Edge::from('task')
->to('profile')
->profileNot('task->status', 'to-do')
->get();
Connecting multiple edges
In real-world projects modeling data as an edge list, you'll frequently run into use cases where a single network request needs to create multiple edges at the same time. If any one of those edges fails to be created, the data would be inconsistent.
For those cases, call the connect
method on your Edge
model, passing an array of edge data. connect
will return an Eloquent collection of created edges:
use App\Models\Edge;
// When the user is viewing a project and creates a task,
// relate the task to the project and to the user who created it:
$edges = Edge::connect([
[
'from_kind' => 'project',
'from' => $projectId,
'kind' => 'requires',
'to_kind' => 'task',
'to' => $taskId,
'profile' => [
'project' => [...],
'task' => [...],
],
],
[
'from_kind' => 'user',
'from' => $userId,
'kind' => 'owns',
'to_kind' => 'task',
'to' => $taskId,
'profile' => [
'user' => [...],
'task' => [...],
],
],
]);
Internally, Baleada Edge uses Laravel's database transactions to ensure that each edges is created with separate queries in a single transaction.
If any one of the queries fails, the entire transaction will be rolled back, and no data will be written to the database.