Oddelenie dát tímov
Pre B2B aplikácie, kde je tím zákazníckou firmou: každý model viazaný na tím číta a zapisuje len riadky aktuálneho tímu, aktuálny tím prejde z requestu aj do jobov vo fronte a zabudnutý kontext nevráti nič namiesto všetkého. Je to voliteľné — BelongsToTeam, HasTeams, roly ani žiadne existujúce dopyty sa nezmenia, kým model nepoužije ScopedToTeam alebo routa middleware teams.context.
Aktuálny tím
use RoundlyConsulting\Teams\Facades\Teams;
Teams::switchTo($team); // one team — CHECKED against the signed-in user
Teams::switchTo($team, $someone); // checked against someone else
Teams::setCurrent($team); // one team — UNCHECKED, for system code only
Teams::current(); // ?Team — null without a context or in multi-team mode
Teams::currentMany(); // Collection<Team> — every team in context
Teams::setCurrent(null); // clear
Teams::runAs($team, function (Team $team) {
// scoped to $team; the previous context is restored afterwards — also on an exception
});
Teams::for($team)->run(fn () => Project::count()); // the same, from the team handle
// Multi-team mode, for "all my teams" dashboards — every team is checked
Teams::setCurrentMany($user->teams()->with('team')->get()->pluck('team'));- switchTo() je spôsob, ako nastaviť tím, ktorý si vybral používateľ. Tím nastaví, len ak používateľ — predvolene prihlásený — má v ňom aktívne členstvo, alebo pri dedení rolí v tíme nad ním. Inak vyhodí TeamContextException a kontext ponechá tak, ako bol; bez akéhokoľvek používateľa vyhodí výnimku tiež.
- setCurrent() nič neoveruje — je určené pre systémový kód, ktorý koná za tím: webhooky, príkazy, seedery. Nikdy mu neodovzdávajte id tímu zo vstupu používateľa.
- setCurrentMany() overí každý tím, všetko alebo nič; prázdna množina neobsiahne nič. Expirované členstvá sa nepočítajú.
- runAs() nič neoveruje, vráti výsledok callbacku a obnoví predchádzajúci kontext — žiadny, jeden tím či množinu — aj vtedy, keď callback vyhodí výnimku. Vrátený PendingDispatch sa odošle vnútri kontextu, takže job nesie správny tím.
- Každá zmena spustí TeamContextSet.
Joby vo fronte a príkazy
Kontext žije v skrytom Context Laravelu, takže sa nikdy neobjaví v logoch a každý job odoslaný počas neho ho pred spustením obnoví. Job odoslaný bez kontextu beží bez neho a kontext jedného jobu sa nikdy neprenesie do ďalšieho. Konzolové príkazy a plánované úlohy začínajú bez kontextu — tím nastavte cez runAs():
// In a request for team A:
Teams::switchTo($teamA);
GenerateInvoices::dispatch(); // runs as team A on the worker
// From a console command or scheduled task, which start without a context:
Teams::runAs($team, fn () => GenerateInvoices::dispatch());Job si kontext zachytí pri odoslaní a beží neskôr. Túto medzeru uzavrie teams.context.recheck_queued_jobs: kontext nastavený cez overenie členstva — middleware, switchTo(), setCurrentMany() — odovzdá svojho člena každému jobu, ktorý z neho odošlete, a keď job začne, člen sa znova overí voči databáze v jej aktuálnom stave. Ak bol odobratý, vypršal či zmazaný, alebo bol zmazaný tím, kontext sa zruší a job zlyhá s TeamContextException ešte pred handle() — bez opakovania, pretože odobraté členstvo sa nevráti. failed() aj JobFailed prebehnú ako pri akomkoľvek zlyhaní. Kontexty z setCurrent() či runAs() člena nenesú a nikdy sa znova neoverujú; kontrola nepokrýva členstvo odobraté, kým handle() už beží. V 2.x je možnosť predvolene vypnutá, v ďalšej hlavnej verzii má byť zapnutá.
Do tímu musí vstúpiť aj balík, ktorý pracuje na pozadí: lifecycle-for-laravel spracuje každý subjekt s ScopedToTeam v Teams::runAs() jeho vlastného tímu, keď s ním pracuje sweep, plány vo fronte, upozornenia na expiráciu, opakovania a lifecycle:adopt — bez akéhokoľvek nastavovania.
Middleware teams.context
Route::middleware(['auth', 'teams.context'])->prefix('/teams/{team}')->group(function () {
Route::get('/projects', [ProjectController::class, 'index']);
Route::get('/projects/{project}', [ProjectController::class, 'show']); // bound inside the context
});
Route::middleware(['auth', 'teams.context:company'])->get('/companies/{company}/invoices', ...);
Route::get('/teams/{team:slug}/projects', ...)->middleware(['auth', 'teams.context']); // slug binding- Prečíta parameter routy — predvolene team, context.route_parameter, alebo teams.context:<názov> pre jednotlivú routu — a naviaže ho cez route binding modelu tímu, takže funguje getRouteKeyName(), {team:slug} aj stĺpce s ULID. Routa bez tohto parametra vyhodí TeamContextException; mäkko zmazané tímy sa nenájdu.
- Pustí ďalej len prihláseného používateľa, pre ktorého platí $team->isAccessibleBy($user), a kontext nastaví cez overené Teams::switchTo().
- Neexistujúci tím, nečlen, expirovaný člen aj neprihlásený používateľ dostanú tú istú odpoveď — predvolene 404, s TEAMS_CONTEXT_DENY_STATUS=403 kód 403 —, takže skúšanie neprezradí nič o iných zákazníkoch.
- Beží tesne pred SubstituteBindings, takže {project} vyššie sa naviaže už vnútri kontextu: id projektu tímu B pod URL tímu A je 404, nikdy nie riadok tímu B.
Modely viazané na tím
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Teams\Traits\ScopedToTeam;
class Project extends Model
{
use ScopedToTeam;
}
class Invoice extends Model
{
use ScopedToTeam;
public const TEAM_COLUMN = 'company_id'; // per-model column, team_id by default
}Stĺpec pridajte makrom migrácie teamScope(). Typ stĺpca určí podľa kľúča tímu — teams.primary_key_type, alebo kľúča podtriedy Team s HasUuids či HasUlids —, nie podľa key_type. Predvolené správanie pri zmazaní je restrict: natrvalo zmazať tím, ktorý ešte vlastní riadky, nejde:
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->teamScope(); // team_id: typed like the team key, indexed, FK onto teams
$table->teamScope('company_id'); // a custom column
$table->teamScope()->cascadeOnDelete(); // returns the ForeignKeyDefinition
$table->teamScope(nullable: true);
});- Čítanie — jeden tím v kontexte filtruje podľa svojho kľúča, množina tímov cez IN (…) a bez kontextu nevráti žiadne riadky, prípadne vyhodí TeamContextException, ak je scoping.without_context nastavené na throw. Platí to pre get, find, findOrFail (404), count, exists, whereHas, eager aj lazy relácie, route-model binding a WHERE hromadných update() a delete().
- Zápis — s jedným tímom v kontexte sa prázdny stĺpec tímu doplní a akýkoľvek iný tím vyhodí výnimku; v režime viacerých tímov musí byť tím zadaný výslovne a byť v množine; bez kontextu vyhodí výnimku každý zápis. Úpravy, mazanie, obnovenie aj trvalé mazanie overia uložený tím aj prípadný nový, ešte pred zápisom.
- Riadok načítaný cez únikový východ zostáva strážený: prečítať riadok tímu B bez scope a uložiť ho z kontextu tímu A vyhodí výnimku.
Únikové východy
Každé miesto, kde sa oddelenie ruší, nájdete cez grep — grep -rn "withoutTeamScope\|forTeam(" app/ ich vypíše všetky:
Project::withoutTeamScope()->count(); // this query ignores the context
Project::forTeam($teamB)->get(); // this query reads team B
Project::forTeam([$a, $b])->get(); // Teams, keys or a collection; [] matches nothing
Teams::withoutTeamScope(fn () => $project->update(['team_id' => $b->id])); // reads and writes, this process onlyČo scope nevidí
Ide o globálny scope Eloquentu a model eventy, preto ho obchádza: DB::table(), toBase() a surové SQL; hromadné insert() a upsert() a nová hodnota tímu v hromadnom update() (jeho WHERE je obmedzené); saveQuietly(), deleteQuietly() a withoutEvents(), ktoré preskočia kontroly zápisu, zatiaľ čo čítanie zostáva obmedzené; a withoutGlobalScopes() bez argumentov, ktoré zahodí scope tímu spolu so všetkými ostatnými — pomenujte radšej scope, ktorý myslíte.
Model iného balíka
Vytvorte podtriedu modelu, pridajte trait a konfiguráciu modelov daného balíka nasmerujte na svoju triedu — globálne scopes a model eventy platia pre konkrétnu triedu, takže obmedzená je len vaša podtrieda:
// tickets-for-laravel ships Ticket; scope it in your app:
class Ticket extends \RoundlyConsulting\Tickets\Models\Ticket
{
use ScopedToTeam;
}
// config/tickets.php
'models' => ['ticket' => App\Models\Ticket::class],Stĺpec pridajte vlastnou migráciou. Iný balík musí model načítavať cez svoju konfiguráciu, ako to robí každý balík Roundly — čokoľvek, čo robí priamo s rodičovskou triedou alebo cez DB::table(), obmedzené nie je. Riadky, ktoré vytvorí, dostanú aktuálny tím a bez kontextu vyhodia výnimku, preto jeho príkazy a joby spúšťajte vnútri Teams::runAs().
Unikátne hodnoty, kľúče cache a súbory
UniqueInTeam overí jedinečnosť medzi riadkami jedného tímu v modeli viazanom na tím. Bez kontextu — alebo v režime viacerých tímov bez inTeam() — vyhodí TeamContextException namiesto hádania:
use RoundlyConsulting\Teams\Rules\UniqueInTeam;
'slug' => ['required', UniqueInTeam::for(Project::class, 'slug')],
'slug' => [UniqueInTeam::for(Project::class, 'slug')->ignore($project)], // a model or a key
'slug' => [UniqueInTeam::for(Project::class, 'slug')->inTeam($team)], // an explicit teamscopedKey() dá každému tímu vlastný prefix pre cache a uložené súbory, zostavený z primárneho kľúča — nikdy z premenovateľného slugu. Segment . alebo .. či bajt NUL vyhodí TeamsException a neuložený tím TeamContextException:
Cache::remember(Teams::current()->scopedKey('stats'), 600, fn () => ...); // teams/42/stats
Storage::put($team->scopedKey('invoices/2026-11.pdf'), $pdf); // teams/42/invoices/2026-11.pdfKontrola teams:doctor
php artisan teams:doctorNajprv skontroluje kľúč tímov: teams.primary_key_type, kľúč, ktorý zapisuje model tímu, a typ každého stĺpca s kľúčom tímu sa musia zhodovať — podtrieda s HasUlids pri nastavení bigint či celočíselný team_id pri tímoch s ULID zlyhá. Potom vypíše každý model viazaný na tím — triedy zo scoping.models a každý konkrétny model so ScopedToTeam v scoping.discover — a zlyhá, ak niektorému chýba tabuľka, stĺpec tímu alebo index, ktorého prvým stĺpcom je stĺpec tímu; bez neho každý request prechádza riadky všetkých tenantov. Stĺpec, ktorý ešte nebol migrovaný, sa len vypíše, nezlyhá. Spúšťajte ju v CI alebo v teste:
it('keeps every team-scoped table safe', function () {
$this->artisan('teams:doctor')->assertSuccessful();
});Programovo vráti Teams::scopedModels() pre každý model ScopedModelReport a Teams::teamKeys() TeamKeyReport, oba s isHealthy() a problems().
Testovanie
InteractsWithTeams pridá actingInTeam($user, $team, ?$guard): prihlási používateľa a nastaví tím ako aktuálny, bez overenia členstva — routy za teams.context si tím aj tak nastavia z URL. Dáta viazané na tím vytvárajte vnútri Teams::runAs():
use RoundlyConsulting\Teams\Testing\Concerns\InteractsWithTeams;
uses(InteractsWithTeams::class)->in('Feature');
it('lists the team projects', function () {
Teams::runAs($team, fn () => Project::factory()->count(3)->create());
$this->actingInTeam($user, $team)
->get('/dashboard')
->assertOk();
});Prejavte lásku k open source
Tento balík je zadarmo pod licenciou MIT. Ak vám šetrí čas, jednorazový príspevok alebo členstvo na Patreone nám pomôže ho ďalej udržiavať, testovať a dokumentovať.
Ďalšie spôsoby podpory vrátane kryptomienOdoslaním daru súhlasíte s našimi podmienkami prijímania darov.
Chcete to zabudovať do svojho produktu?
Naše balíky integrujeme do zákazkových Laravel a AI riešení. Napíšte nám, na čom pracujete, a ozveme sa do 48 hodín.