Gitlab offers a REST API
Almost all tasks can be done through the API
Without using a git binary
https://docs.gitlab.com/api/rest/
m4tthumphrey/php-gitlab-api (mentioned by Gitlab itself)
Verb-complete implementation
Pure userland implementation without the need of a git binary or extension
A personal access token will inherit the user level of the user on the respecting project!
With a project level access token, the project-id or project-path must be provided for almost all API-actions
An OAUTH2 Token can be used as well, with Gitlab as an IdP, but that is probably not practical for automated processes
When running a tool inside a CI/CD queue, the CI_JOB_TOKEN can be used to access the Gitlab API
$client = new Gitlab\Client();
$client->setUrl(getenv('GITLAB_SERVER')); // https://gitlab.com/
$client->authenticate(
getenv('GITLAB_ACCESS_TOKEN'), // glpat-XXXXX-XXX....
Gitlab\Client::AUTH_HTTP_TOKEN
);
Any PSR-18 HTTP-Client can be injected as well
$projects = $client
->projects()
->all();
foreach($projects as $project) {
printf("%d %s (%s)\n",
$project['id'],
$project['name_with_namespace'],
$project['path_with_namespace']
);
}
$projects = $client
->projects()
->all(['membership'=>true]);
foreach($projects as $project) {
printf("%d %s (%s)\n",
$project['id'],
$project['name_with_namespace'],
$project['path_with_namespace']
);
}
Reminder: a user-level access token will give access to everything the user can see
{
"id": 75547854,
"description": null,
"name": "Demo Talk",
"name_with_namespace": "Frank Berger / Demo Talk",
"path": "demo-talk",
"path_with_namespace": "foppelfb/demo-talk",
"created_at": "2025-10-23T14:06:26.665Z",
"default_branch": "main",
"tag_list": [],
"topics": [],
"ssh_url_to_repo": "git@gitlab.com:foppelfb/demo-talk.git",
"http_url_to_repo": "https://gitlab.com/foppelfb/demo-talk.git",
"web_url": "https://gitlab.com/foppelfb/demo-talk",
"readme_url": "https://gitlab.com/foppelfb/demo-talk/-/blob/main/README.md",
"forks_count": 0,
"avatar_url": null,
"star_count": 0,
"last_activity_at": "2025-10-23T14:06:26.572Z",
"visibility": "private",
"namespace": {
"id": 4658152,
"name": "Frank Berger",
"path": "foppelfb",
"kind": "user",
"full_path": "foppelfb",
"parent_id": null,
"avatar_url": "https://secure.gravatar.com/avatar/c9f3b5a9949724de5da93fcf1ea189dd31131a25502fb8555eb7cff3fab5cfad?s=80&d=identicon",
"web_url": "https://gitlab.com/foppelfb"
},
"container_registry_image_prefix": "registry.gitlab.com/foppelfb/demo-talk",
"_links": {
"self": "https://gitlab.com/api/v4/projects/75547854",
"issues": "https://gitlab.com/api/v4/projects/75547854/issues",
"merge_requests": "https://gitlab.com/api/v4/projects/75547854/merge_requests",
"repo_branches": "https://gitlab.com/api/v4/projects/75547854/repository/branches",
"labels": "https://gitlab.com/api/v4/projects/75547854/labels",
"events": "https://gitlab.com/api/v4/projects/75547854/events",
"members": "https://gitlab.com/api/v4/projects/75547854/members",
"cluster_agents": "https://gitlab.com/api/v4/projects/75547854/cluster_agents"
},
"marked_for_deletion_at": null,
"marked_for_deletion_on": null,
"packages_enabled": true,
"empty_repo": false,
"archived": false,
"owner": {
"id": 3583153,
"username": "foppelfb",
"public_email": "",
"name": "Frank Berger",
"state": "active",
"locked": false,
"avatar_url": "https://secure.gravatar.com/avatar/c9f3b5a9949724de5da93fcf1ea189dd31131a25502fb8555eb7cff3fab5cfad?s=80&d=identicon",
"web_url": "https://gitlab.com/foppelfb"
},
"resolve_outdated_diff_discussions": false,
"container_expiration_policy": {
"cadence": "1d",
"enabled": false,
"keep_n": 10,
"older_than": "90d",
"name_regex": ".*",
"name_regex_keep": null,
"next_run_at": "2025-10-24T14:06:26.695Z"
},
"repository_object_format": "sha1",
"issues_enabled": true,
"merge_requests_enabled": true,
"wiki_enabled": true,
"jobs_enabled": true,
"snippets_enabled": true,
"container_registry_enabled": true,
"service_desk_enabled": true,
"service_desk_address": "contact-project+foppelfb-demo-talk-75547854-issue-@incoming.gitlab.com",
"can_create_merge_request_in": true,
"issues_access_level": "enabled",
"repository_access_level": "enabled",
"merge_requests_access_level": "enabled",
"forking_access_level": "enabled",
"wiki_access_level": "enabled",
"builds_access_level": "enabled",
"snippets_access_level": "enabled",
"pages_access_level": "private",
"analytics_access_level": "enabled",
"container_registry_access_level": "enabled",
"security_and_compliance_access_level": "private",
"releases_access_level": "enabled",
"environments_access_level": "enabled",
"feature_flags_access_level": "enabled",
"infrastructure_access_level": "enabled",
"monitor_access_level": "enabled",
"model_experiments_access_level": "enabled",
"model_registry_access_level": "enabled",
"package_registry_access_level": "enabled",
"emails_disabled": false,
"emails_enabled": true,
"show_diff_preview_in_email": true,
"shared_runners_enabled": true,
"lfs_enabled": true,
"creator_id": 3583153,
"import_status": "none",
"open_issues_count": 1,
"description_html": "",
"updated_at": "2025-10-23T14:06:28.259Z",
"ci_config_path": "",
"public_jobs": true,
"shared_with_groups": [],
"only_allow_merge_if_pipeline_succeeds": false,
"allow_merge_on_skipped_pipeline": null,
"request_access_enabled": true,
"only_allow_merge_if_all_discussions_are_resolved": false,
"remove_source_branch_after_merge": true,
"printing_merge_request_link_enabled": true,
"merge_method": "merge",
"merge_request_title_regex": null,
"merge_request_title_regex_description": null,
"squash_option": "default_off",
"enforce_auth_checks_on_uploads": true,
"suggestion_commit_message": null,
"merge_commit_template": null,
"squash_commit_template": null,
"issue_branch_template": null,
"warn_about_potentially_unwanted_characters": true,
"autoclose_referenced_issues": true,
"max_artifacts_size": null,
"external_authorization_classification_label": "",
"requirements_enabled": false,
"requirements_access_level": "enabled",
"security_and_compliance_enabled": true,
"compliance_frameworks": [],
"duo_remote_flows_enabled": true,
"permissions": {
"project_access": {
"access_level": 30,
"notification_level": 3
},
"group_access": null
}
}
$issues = $client
->issues()
->all('foppelfb/demo-talk'); // id or path
foreach($issues as $issue) {
printf("[%d] ID %d in %d: %s (%s)\n%s\n",
$issue['id'],
$issue['iid'],
$issue['project_id'],
$issue['title'],
$issue['author']['name'],
$issue['web_url']
);
}
There is a Pager Strategy available
$issue = $client
->issues()
->create('foppelfb/demo-talk',[
'title'=>$title,
'description'=>$body,
]);
printf("[%d] ID %d in %s: %s (%s)\n%s\n",
$issue['id'],
$issue['iid'],
$issue['project_id'],
$issue['title'],
$issue['author']['name'],
$issue['web_url']
);
Tip: use different access tokens for different tasks
$client->projects();
$client->issues();
$client->version();
$client->deployKeys(); // readonly
$client->environments();
$client->deployments();
$client->jobs();
$client->users();
$client->groups();
$client->tags();
$client->wiki();
$client->repositories();
$client->repositoryFiles();
and a few more
$branches = $client
->repositories()
->branches('foppelfb/demo-talk');
foreach($branches as $branch) {
printf("%s : Last commit %s (%s)\n%s: %s\n\n",
$branch['name'],
$branch['commit']['short_id'],
$branch['commit']['created_at'],
$branch['commit']['author_name'],
$branch['commit']['title']
);
}
$branch['commit'] == HEAD
try {
$branch = $client->repositories()
->createBranch('foppelfb/demo-talk',$newBranch, 'main');
printf("%s : Last commit %s (%s)\n%s: %s\n\n",
$branch['name'], $branch['commit']['short_id'],
$branch['commit']['created_at'],
$branch['commit']['author_name'],
$branch['commit']['title']
);
} catch(\Gitlab\Exception\ValidationFailedException $e) {
echo $e->getMessage(),"\n";
}
$file = 'my/path/file.txt';
try {
$file = $client->repositoryFiles()
->getFile('foppelfb/demo-talk', $file, 'main');
} catch (\Gitlab\Exception\RuntimeException $e) {
// $e->getCode() == 404
$file = null;
}
try {
$raw = $client->repositoryFiles()
->getRawFile('foppelfb/demo-talk', $file, 'main');
} catch (\Gitlab\Exception\RuntimeException $e) {
$raw = null;
}
this is the only (atomic) way to check if a file exists
$client->repositoryFiles()
->getFile('foppelfb/demo-talk', $file, ref: 'main');
'main' is a reference, can be a commit hash as well as a branch
$payload = [
'file_path' => $file, // path/to/my/file.txt
'branch' => $branch,
'content' => $content, // plain text or base64
'commit_message' => $message,
'author_email' => 'fberger@sudhaus7.de',
'author_name' => 'Its a me, Franky',
];
if ($raw !== null) { // update
$result = $client->repositoryFiles()
->updateFile('foppelfb/demo-talk', $payload);
} else { //create
$result = $client->repositoryFiles()
->createFile('foppelfb/demo-talk', $payload);
}
All writing methods create singular commits in the repository
$commit = $client->repositories()
->createCommit( 'foppelfb/demo-talk', parameters: [
'branch'=>$branch,
'commit_message'=>$commitmessage,
'actions'=>[
[
'action'=>'move',
'previous_path'=>$oldfilename,
'file_path'=> $newfilename,
]
],
'author_email'=>'fberger@sudhaus7.de',
'author_name'=>'The other Franky',
] );
Possible actions are: create, delete, move, update, chmod
You can add several actions and create a branch in one go
$parameters = [
'remove_source_branch'=>true,
'squash'=>true,
'assignee_id'=>123456,
];
try {
$mr = $client->mergeRequests()->create(
'foppelfb/demo-talk',
$srcBranch,
$tgtBranch,
$message,
$parameters
);
} catch (\Gitlab\Exception\RuntimeException $e) {
// $e->getCode() == 409 -> merge request exists
echo $e->getCode(),' ',$e->getMessage(),"\n";
}
check $mr['merge_status'] and $mr['detailed_merge_status'] if you want to merge
$mr = $client->mergeRequests()
->show( 'foppelfb/demo-talk', $iid);
if (
$mr['detailed_merge_status'] === 'mergeable' &&
(int)$mr['user']['can_merge']===1
) {
$mrResult = $client->mergeRequests()
->merge('foppelfb/demo-talk', $iid, []);
printf("%s\n",$mrResult['merged_at']);
} else {
printf("ERROR: %s: %s - user can merge: %d\n",
$mr['merge_status'], $mr['detailed_merge_status'],
$mr['user']['can_merge']
);
}
rebase is available as well, of course
code711/siteconfiggitsync | EXT:siteconfiggitsync
An extension for TYPO3 that allows to keep the configuration for sites and site-sets (YAML Config files) - which can be edited through the backend by admins/editors - managed in GIT
Has support for GitHub as well
Customer facing we have a Redmine system
Developers use code-related tickets in Gitlab
Tools keep the ticket relations and release management in sync
Twitter: @FoppelFB | Mastodon: @foppel@phpc.social
fberger@sudhaus7.de | https://sudhaus7.de/