Build a team-scoped CRUD
The resource base class that keeps a Laravel Front CRUD inside the team, and how to register its route and permissions.
Weblabor Base builds its admin screens with Laravel Front: a resource class declares the fields and the kit renders the list, the forms and the detail page. See The admin panel on the base's site. Inside a team the same tool works, with one base class that keeps every record and every permission check inside the team. This guide shows it.
The base class
app/Front/Resources/Team/Resource.php:
namespace App\Front\Resources\Team;
use App\Front\Resources\Resource as Base;
use Illuminate\Database\Eloquent\Model;
class Resource extends Base
{
public $guards = ['team'];
public $layout = 'layouts.teams';
public $section = 'team';
public function indexQuery($query)
{
return $query->where('team_id', team()->id)->latest();
}
public function processDataBeforeSaving($data)
{
$data['team_id'] = team()->id;
return $data;
}
public function mediaOwner(): ?Model
{
return team();
}
}
| What it sets | What it means |
|---|---|
$guards = ['team'] |
The retrieve, create, update and delete permissions of the resource are created on the team guard and checked against the team's roles. |
$layout = 'layouts.teams' |
The screens render inside the team workspace, with its sidebar. |
$section = 'team' |
The resource is listed in the team workspace's sidebar, and not in the admin panel's. |
indexQuery |
The list shows only the current team's records. |
processDataBeforeSaving |
A record created or updated from the form belongs to the current team, whatever the request carried. |
mediaOwner |
An image or file field of the resource uploads into the team's file library and counts against the team's space, not the member's. |
A resource that extends the base's App\Front\Resources\Resource instead is wrong in three ways at once: its permissions land on the web guard, so a member with the right team role is refused; its list shows every team's records; and its records are saved with no team_id.
Writing one
- The table needs a
team_idcolumn, indexed, pointing atteams. - Create the class under
app/Front/Resources/Team/:
namespace App\Front\Resources\Team;
use App\Models\Project as Model;
use WeblaborMx\Front\Inputs;
class Project extends Resource
{
public $base_url = '/team/projects';
public $model = Model::class;
public $icon = 'folder';
public function fields()
{
return [
Inputs\ID::make(),
Inputs\Text::make('Name')->rules('required'),
Inputs\Textarea::make('Description')->hideFromIndex(),
];
}
}
- Register it in
routes/team.php. That file already runs inside the team group, with theauth,securityandteammiddleware, so theteammiddleware sets the context before any check runs, and the line needs no group of its own:
// routes/team.php
Route::front('Team\\Project');
The group asks for no subscription. Your screen is protected by its own permissions, the ones the next step seeds. Requiring a subscription is your decision: when the screen has to stay closed to a team without an active plan, check it in the screen itself, for example with team()->subscription('default')?->active(). Do not use ensure.subscribed:team for it, because it also asks for manage plans and shuts out every member without that permission.
Laravel Front names the routes team.front.projects, team.front.projects.create and so on.
- Seed the permissions:
php artisan db:seed --class=PermissionSeeder
With discover_front_permissions on in config/app.php, the seeder finds the resource and creates retrieve project, create project, update project and delete project on the team guard: the singular of the model's table name. The seeder only creates permissions; it gives them to no role. A team created from then on gets them in its Administrator role, because TeamObserver::created() gives that role every team permission except those listed in team_restricted_permissions. The teams that already exist do not: write a migration that gives the new permissions to every role named team_owner_role on the team guard, as 2026_10_14_100001_create_the_manage_media_permission_of_the_team_guard.php does for manage media. Every other member gets them through a role. See Team roles and permissions.
- The sidebar needs no edit. Because of
$section = 'team', the team workspace's sidebar lists the resource, under its plural label, for every member whose role passes the policy'sviewAny. Setpublic $menu_group = 'Team';to place it in the Team group, next to Members and Roles, or'Billing','Files'or'Administration'for one of the other groups; without it the link sits at the top, next to Dashboard. The hand-written links, such as members, settings and media, are inresources/views/components/weblabor-teams/layouts/sidebars/team.blade.php.
The policy
Laravel Front asks the model's policy. Extend App\Policies\BasePolicy and name the permission:
namespace App\Policies;
class ProjectPolicy extends BasePolicy
{
protected string $name = 'project';
}
The property is protected string $name, as BasePolicy declares it: a public $name is a fatal error when the class loads. The name is the second word of the permissions the seeder created, project here.
BasePolicy checks the right guard from the context and refuses a record of another team on view, update and delete. restore and forceDelete also ask that the person hold the team's owner role (team_owner_role). Two optional properties close the whole policy: protected $requiredFeature = 'projects'; refuses every check while the current team lacks that capability, and protected $requiredConfig = 'features.projects'; refuses it while that config value is off. Override a method only for a rule of your own, and call the parent first so the team checks stay.
The one in the kit
App\Front\Resources\Team\Role, at /team/roles, is the team's roles CRUD and the example to copy for the scoping: it extends the admin panel's App\Front\Resources\Role, not the base class above, and sets the same $guards, $layout and $section itself, scopes its list with indexQuery, writes team_id and the team guard in processDataBeforeSaving, sets $menu_group = 'Team', and adds the clone action. Its route is registered the same way, with Route::front('Team\\Role').