NovinkaZverejnili sme 50+ Laravel balíkov ako open source
Custom AI apps, agents and automation — Roundly ConsultingRoundly
Všetky balíky
Teams for Laravel

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 team

scopedKey() 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.pdf

Kontrola teams:doctor

php artisan teams:doctor

Najprv 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 kryptomien

Odoslaní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.